From 7e65fec24aabf214f7760d2828d9649a6ee0b694 Mon Sep 17 00:00:00 2001 From: xiaoxue Date: Mon, 25 May 2026 00:25:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=AE=8C=E6=95=B4=E6=BA=90?= =?UTF-8?q?=E7=A0=81=20-=202026-05-25?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/README.md | 6 + .changeset/abort-handlers-on-close.md | 5 + .changeset/add-fastify-middleware.md | 5 + .changeset/add-hono-peer-dep.md | 5 + .changeset/add-resource-size-field.md | 5 + .changeset/add-sdk-http-error.md | 6 + .changeset/brave-lions-glow.md | 5 + .changeset/busy-rice-smoke.md | 6 + .changeset/busy-weeks-hang.md | 6 + .changeset/cfworker-out-of-barrel.md | 6 + .changeset/config.json | 17 + .changeset/custom-methods-minimal.md | 9 + .changeset/cyan-cycles-pump.md | 5 + .changeset/drop-zod-peer-dep.md | 9 + .changeset/export-inmemory-transport.md | 6 + .changeset/expose-auth-server-discovery.md | 10 + .changeset/express-resource-server-auth.md | 5 + .changeset/extract-task-manager.md | 10 + .changeset/fast-dragons-lead.md | 10 + .changeset/finish-sdkerror-capability.md | 7 + .changeset/fix-abort-listener-leak.md | 5 + .../fix-failed-task-result-retrieval.md | 5 + .changeset/fix-oauth-5xx-discovery.md | 5 + .changeset/fix-onerror-callbacks.md | 5 + .changeset/fix-server-protocol-version.md | 5 + .changeset/fix-session-status-codes.md | 5 + .changeset/fix-stdio-epipe-crash.md | 5 + .changeset/fix-stdio-windows-hide.md | 5 + .changeset/fix-streamable-close-reentrant.md | 5 + .../fix-streamable-http-error-response.md | 5 + .changeset/fix-task-session-isolation.md | 5 + ...transport-exact-optional-property-types.md | 9 + .changeset/fix-unknown-tool-protocol-error.md | 15 + .../fix-validate-client-metadata-url.md | 9 + .changeset/funky-baths-attack.md | 8 + .changeset/gentle-planets-rest.md | 7 + .changeset/heavy-walls-swim.md | 5 + .changeset/hono-peer-optional.md | 5 + .changeset/legacy-module-resolution-types.md | 10 + .changeset/oauth-error-http200.md | 7 + .changeset/odd-forks-enjoy.md | 7 + .changeset/pre.json | 68 + .changeset/quick-islands-occur.md | 10 + .changeset/reconnection-scheduler.md | 5 + .changeset/register-rawshape-compat.md | 8 + .changeset/remove-websocket-transport.md | 5 + .changeset/respect-capability-negotiation.md | 14 + .changeset/rich-hounds-report.md | 10 + .changeset/schema-object-type-for-unions.md | 5 + .changeset/shy-times-learn.md | 8 + .changeset/spec-type-schema.md | 6 + .changeset/spotty-cats-tickle.md | 5 + .changeset/stdio-skip-non-json.md | 5 + .changeset/stdio-subpath-export.md | 6 + .changeset/support-standard-json-schema.md | 34 + .changeset/tame-camels-greet.md | 9 + .changeset/tender-snails-fold.md | 6 + .changeset/token-provider-composable-auth.md | 16 + .changeset/twelve-dodos-taste.md | 5 + .changeset/use-scopes-supported-in-dcr.md | 10 + .changeset/wraphandler-hook.md | 7 + .changeset/zod-json-schema-compat.md | 5 + .changeset/zod-jsonschema-fallback.md | 7 + .git-blame-ignore-revs | 0 .github/CODEOWNERS | 11 + .github/dependabot.yml | 6 + .github/workflows/claude.yml | 41 + .github/workflows/conformance.yml | 51 + .github/workflows/deploy-docs.yml | 53 + .github/workflows/main.yml | 94 + .github/workflows/publish.yml | 43 + .github/workflows/release.yml | 82 + .github/workflows/update-spec-types.yml | 84 + .gitignore | 57 + .npmrc | 1 + .prettierignore | 21 + .prettierrc.json | 20 + CLAUDE.md | 280 + CODE_OF_CONDUCT.md | 83 + CONTRIBUTING.md | 187 + LICENSE | 216 + README.md | 168 + REVIEW.md | 95 + SECURITY.md | 21 + common/eslint-config/eslint.config.mjs | 109 + common/eslint-config/package.json | 37 + common/tsconfig/package.json | 21 + common/tsconfig/tsconfig.json | 31 + common/vitest-config/package.json | 28 + common/vitest-config/tsconfig.json | 8 + common/vitest-config/vitest.config.js | 25 + docs/client-quickstart.md | 424 + docs/client.md | 626 ++ docs/documents.md | 17 + docs/faq.md | 85 + docs/migration-SKILL.md | 548 + docs/migration.md | 970 ++ docs/server-quickstart.md | 476 + docs/server.md | 592 ++ docs/v2-banner.js | 9 + examples/client-quickstart/.gitignore | 1 + examples/client-quickstart/package.json | 21 + examples/client-quickstart/src/index.ts | 188 + examples/client-quickstart/tsconfig.json | 27 + examples/client/README.md | 52 + examples/client/eslint.config.mjs | 14 + examples/client/package.json | 47 + examples/client/src/clientGuide.examples.ts | 575 ++ examples/client/src/customMethodExample.ts | 25 + examples/client/src/dualModeAuth.ts | 114 + examples/client/src/elicitationUrlExample.ts | 824 ++ .../client/src/multipleClientsParallel.ts | 152 + .../client/src/parallelToolCallsClient.ts | 175 + .../client/src/simpleClientCredentials.ts | 83 + examples/client/src/simpleOAuthClient.ts | 469 + .../client/src/simpleOAuthClientProvider.ts | 69 + examples/client/src/simpleStreamableHttp.ts | 1008 ++ .../client/src/simpleTaskInteractiveClient.ts | 204 + examples/client/src/simpleTokenProvider.ts | 55 + examples/client/src/ssePollingClient.ts | 109 + .../streamableHttpWithSseFallbackClient.ts | 181 + examples/client/tsconfig.json | 22 + examples/client/tsdown.config.ts | 25 + examples/client/vitest.config.js | 3 + examples/server-quickstart/.gitignore | 1 + examples/server-quickstart/package.json | 21 + examples/server-quickstart/src/index.ts | 222 + examples/server-quickstart/tsconfig.json | 26 + examples/server/README.md | 173 + examples/server/eslint.config.mjs | 14 + examples/server/package.json | 58 + .../src/README-simpleTaskInteractive.md | 181 + examples/server/src/arktypeExample.ts | 29 + examples/server/src/customMethodExample.ts | 23 + examples/server/src/customProtocolVersion.ts | 65 + examples/server/src/elicitationFormExample.ts | 488 + examples/server/src/elicitationUrlExample.ts | 738 ++ .../src/honoWebStandardStreamableHttp.ts | 73 + examples/server/src/inMemoryEventStore.ts | 77 + .../server/src/jsonResponseStreamableHttp.ts | 168 + examples/server/src/mcpServerOutputSchema.ts | 83 + examples/server/src/resourceServerOnly.ts | 87 + examples/server/src/serverGuide.examples.ts | 560 + .../src/simpleStatelessStreamableHttp.ts | 171 + examples/server/src/simpleStreamableHttp.ts | 821 ++ examples/server/src/simpleTaskInteractive.ts | 758 ++ examples/server/src/ssePollingExample.ts | 135 + .../src/standaloneSseWithGetStreamableHttp.ts | 168 + examples/server/src/toolWithSampleServer.ts | 60 + examples/server/src/valibotExample.ts | 31 + examples/server/tsconfig.json | 25 + examples/server/tsdown.config.ts | 25 + examples/server/vitest.config.js | 3 + examples/shared/eslint.config.mjs | 14 + examples/shared/package.json | 62 + examples/shared/src/auth.ts | 250 + examples/shared/src/authServer.ts | 314 + examples/shared/src/index.ts | 7 + .../test/demoInMemoryOAuthProvider.test.ts | 37 + examples/shared/tsconfig.json | 27 + examples/shared/vitest.config.js | 3 + lefthook-local.example.yml | 23 + lefthook.yml | 37 + package.json | 83 + packages/client/CHANGELOG.md | 151 + packages/client/README.md | 24 + packages/client/eslint.config.mjs | 12 + packages/client/package.json | 111 + packages/client/src/client/auth.examples.ts | 63 + packages/client/src/client/auth.ts | 1745 ++++ .../src/client/authExtensions.examples.ts | 62 + packages/client/src/client/authExtensions.ts | 702 ++ packages/client/src/client/client.examples.ts | 194 + packages/client/src/client/client.ts | 1060 ++ packages/client/src/client/crossAppAccess.ts | 303 + .../client/src/client/middleware.examples.ts | 89 + packages/client/src/client/middleware.ts | 319 + packages/client/src/client/sse.ts | 319 + packages/client/src/client/stdio.ts | 260 + .../src/client/streamableHttp.examples.ts | 31 + packages/client/src/client/streamableHttp.ts | 770 ++ packages/client/src/experimental/index.ts | 13 + .../src/experimental/tasks/client.examples.ts | 70 + .../client/src/experimental/tasks/client.ts | 277 + packages/client/src/fromJsonSchema.ts | 9 + packages/client/src/index.ts | 81 + packages/client/src/shimsBrowser.ts | 13 + packages/client/src/shimsNode.ts | 13 + packages/client/src/shimsWorkerd.ts | 13 + packages/client/src/stdio.ts | 8 + packages/client/src/validators/cfWorker.ts | 10 + packages/client/test/client/auth.test.ts | 4133 ++++++++ .../client/test/client/authExtensions.test.ts | 768 ++ .../client/test/client/barrelClean.test.ts | 55 + .../client/test/client/crossAppAccess.test.ts | 428 + .../client/test/client/crossSpawn.test.ts | 205 + .../client/test/client/middleware.test.ts | 1119 ++ packages/client/test/client/sse.test.ts | 1700 +++ packages/client/test/client/stdio.test.ts | 79 + .../client/test/client/streamableHttp.test.ts | 2022 ++++ .../client/test/client/tokenProvider.test.ts | 316 + packages/client/tsconfig.json | 17 + packages/client/tsdown.config.ts | 40 + packages/client/typedoc.json | 10 + packages/client/vitest.config.js | 8 + packages/client/vitest.setup.js | 7 + packages/codemod/batch-test/.gitignore | 3 + packages/codemod/batch-test/README.md | 102 + packages/codemod/batch-test/analyze-prompt.md | 87 + packages/codemod/batch-test/repos.json | 44 + packages/codemod/eslint.config.mjs | 5 + packages/codemod/package.json | 71 + .../codemod/scripts/generateSpecSchemaMap.ts | 39 + packages/codemod/scripts/generateVersions.ts | 34 + packages/codemod/src/bin/batchTest.ts | 463 + packages/codemod/src/cli.ts | 158 + .../codemod/src/generated/specSchemaMap.ts | 170 + packages/codemod/src/generated/versions.ts | 7 + packages/codemod/src/index.ts | 14 + packages/codemod/src/migrations/index.ts | 12 + .../codemod/src/migrations/v1-to-v2/index.ts | 8 + .../v1-to-v2/mappings/contextPropertyMap.ts | 23 + .../migrations/v1-to-v2/mappings/importMap.ts | 168 + .../v1-to-v2/mappings/schemaToMethodMap.ts | 36 + .../migrations/v1-to-v2/mappings/symbolMap.ts | 11 + .../v1-to-v2/transforms/contextTypes.ts | 257 + .../v1-to-v2/transforms/expressMiddleware.ts | 61 + .../transforms/handlerRegistration.ts | 64 + .../v1-to-v2/transforms/importPaths.ts | 288 + .../migrations/v1-to-v2/transforms/index.ts | 51 + .../v1-to-v2/transforms/mcpServerApi.ts | 523 + .../v1-to-v2/transforms/mockPaths.ts | 325 + .../v1-to-v2/transforms/removedApis.ts | 192 + .../v1-to-v2/transforms/schemaParamRemoval.ts | 43 + .../v1-to-v2/transforms/specSchemaAccess.ts | 350 + .../v1-to-v2/transforms/symbolRenames.ts | 352 + packages/codemod/src/runner.ts | 132 + packages/codemod/src/types.ts | 65 + packages/codemod/src/utils/astUtils.ts | 33 + packages/codemod/src/utils/diagnostics.ts | 29 + packages/codemod/src/utils/importUtils.ts | 141 + .../codemod/src/utils/packageJsonUpdater.ts | 78 + packages/codemod/src/utils/projectAnalyzer.ts | 75 + packages/codemod/test/cli.test.ts | 151 + packages/codemod/test/integration.test.ts | 655 ++ .../codemod/test/packageJsonUpdater.test.ts | 299 + packages/codemod/test/projectAnalyzer.test.ts | 130 + .../v1-to-v2/transforms/contextTypes.test.ts | 443 + .../transforms/expressMiddleware.test.ts | 120 + .../transforms/handlerRegistration.test.ts | 276 + .../v1-to-v2/transforms/importPaths.test.ts | 451 + .../v1-to-v2/transforms/mcpServerApi.test.ts | 383 + .../v1-to-v2/transforms/mockPaths.test.ts | 318 + .../v1-to-v2/transforms/removedApis.test.ts | 270 + .../transforms/schemaParamRemoval.test.ts | 120 + .../transforms/specSchemaAccess.test.ts | 507 + .../v1-to-v2/transforms/symbolRenames.test.ts | 472 + packages/codemod/tsconfig.json | 10 + packages/codemod/tsdown.config.ts | 16 + packages/codemod/typedoc.json | 10 + packages/codemod/vitest.config.js | 3 + packages/core/CHANGELOG.md | 106 + packages/core/eslint.config.mjs | 5 + packages/core/package.json | 91 + packages/core/src/auth/errors.ts | 132 + .../core/src/errors/sdkErrors.examples.ts | 39 + packages/core/src/errors/sdkErrors.ts | 110 + packages/core/src/experimental/index.ts | 3 + .../core/src/experimental/tasks/helpers.ts | 104 + .../core/src/experimental/tasks/interfaces.ts | 243 + .../src/experimental/tasks/stores/inMemory.ts | 313 + packages/core/src/exports/public/index.ts | 149 + packages/core/src/exports/types/index.ts | 1 + packages/core/src/index.examples.ts | 31 + packages/core/src/index.ts | 56 + packages/core/src/shared/auth.ts | 252 + packages/core/src/shared/authUtils.ts | 57 + packages/core/src/shared/metadataUtils.ts | 26 + packages/core/src/shared/protocol.examples.ts | 29 + packages/core/src/shared/protocol.ts | 1236 +++ packages/core/src/shared/responseMessage.ts | 98 + packages/core/src/shared/stdio.ts | 50 + packages/core/src/shared/taskManager.ts | 915 ++ .../core/src/shared/toolNameValidation.ts | 116 + packages/core/src/shared/transport.ts | 134 + packages/core/src/shared/uriTemplate.ts | 290 + packages/core/src/types/constants.ts | 15 + packages/core/src/types/enums.ts | 16 + packages/core/src/types/errors.ts | 49 + packages/core/src/types/guards.ts | 110 + packages/core/src/types/index.ts | 9 + packages/core/src/types/schemas.ts | 2241 ++++ packages/core/src/types/spec.types.ts | 3250 ++++++ .../core/src/types/specTypeSchema.examples.ts | 40 + packages/core/src/types/specTypeSchema.ts | 296 + packages/core/src/types/types.ts | 562 + packages/core/src/util/inMemory.ts | 73 + packages/core/src/util/schema.ts | 32 + packages/core/src/util/standardSchema.ts | 251 + packages/core/src/util/zodCompat.ts | 80 + .../src/validators/ajvProvider.examples.ts | 48 + packages/core/src/validators/ajvProvider.ts | 94 + .../validators/cfWorkerProvider.examples.ts | 33 + .../core/src/validators/cfWorkerProvider.ts | 79 + .../src/validators/fromJsonSchema.examples.ts | 24 + .../core/src/validators/fromJsonSchema.ts | 43 + .../core/src/validators/types.examples.ts | 31 + packages/core/src/validators/types.ts | 59 + .../core/test/experimental/inMemory.test.ts | 1035 ++ packages/core/test/inMemory.test.ts | 165 + packages/core/test/shared/auth.test.ts | 122 + packages/core/test/shared/authUtils.test.ts | 90 + .../core/test/shared/customMethods.test.ts | 200 + packages/core/test/shared/protocol.test.ts | 5680 ++++++++++ .../shared/protocolTransportHandling.test.ts | 125 + packages/core/test/shared/stdio.test.ts | 115 + .../test/shared/toolNameValidation.test.ts | 130 + packages/core/test/shared/transport.test.ts | 182 + packages/core/test/shared/uriTemplate.test.ts | 314 + packages/core/test/shared/wrapHandler.test.ts | 35 + packages/core/test/spec.types.test.ts | 1123 ++ packages/core/test/types.capabilities.test.ts | 103 + packages/core/test/types.test.ts | 1014 ++ packages/core/test/types/guards.test.ts | 123 + .../core/test/types/specTypeSchema.test.ts | 176 + .../core/test/util/standardSchema.test.ts | 42 + .../util/standardSchema.zodFallback.test.ts | 37 + packages/core/test/util/zodCompat.test.ts | 89 + .../core/test/validators/validators.test.ts | 625 ++ packages/core/tsconfig.json | 12 + packages/core/vitest.config.js | 3 + packages/middleware/README.md | 23 + packages/middleware/express/CHANGELOG.md | 34 + packages/middleware/express/README.md | 68 + packages/middleware/express/eslint.config.mjs | 12 + packages/middleware/express/package.json | 73 + .../middleware/express/src/auth/bearerAuth.ts | 120 + .../express/src/auth/metadataRouter.ts | 153 + packages/middleware/express/src/auth/types.ts | 37 + .../express/src/express.examples.ts | 41 + packages/middleware/express/src/express.ts | 88 + packages/middleware/express/src/index.ts | 9 + .../hostHeaderValidation.examples.ts | 31 + .../src/middleware/hostHeaderValidation.ts | 52 + .../express/test/auth/resourceServer.test.ts | 218 + .../middleware/express/test/express.test.ts | 192 + packages/middleware/express/tsconfig.json | 18 + packages/middleware/express/tsdown.config.ts | 22 + packages/middleware/express/typedoc.json | 10 + packages/middleware/express/vitest.config.js | 3 + packages/middleware/fastify/CHANGELOG.md | 31 + packages/middleware/fastify/README.md | 70 + packages/middleware/fastify/eslint.config.mjs | 12 + packages/middleware/fastify/package.json | 66 + .../fastify/src/fastify.examples.ts | 41 + packages/middleware/fastify/src/fastify.ts | 82 + packages/middleware/fastify/src/index.ts | 2 + .../hostHeaderValidation.examples.ts | 30 + .../src/middleware/hostHeaderValidation.ts | 49 + .../middleware/fastify/test/fastify.test.ts | 209 + packages/middleware/fastify/tsconfig.json | 15 + packages/middleware/fastify/tsdown.config.ts | 22 + packages/middleware/fastify/typedoc.json | 10 + packages/middleware/fastify/vitest.config.js | 3 + packages/middleware/hono/CHANGELOG.md | 31 + packages/middleware/hono/README.md | 48 + packages/middleware/hono/eslint.config.mjs | 12 + packages/middleware/hono/package.json | 66 + packages/middleware/hono/src/hono.ts | 90 + packages/middleware/hono/src/index.ts | 2 + .../src/middleware/hostHeaderValidation.ts | 33 + packages/middleware/hono/test/hono.test.ts | 109 + packages/middleware/hono/tsconfig.json | 18 + packages/middleware/hono/tsdown.config.ts | 22 + packages/middleware/hono/typedoc.json | 10 + packages/middleware/hono/vitest.config.js | 3 + packages/middleware/node/CHANGELOG.md | 43 + packages/middleware/node/README.md | 55 + packages/middleware/node/eslint.config.mjs | 12 + packages/middleware/node/package.json | 84 + packages/middleware/node/src/index.ts | 1 + .../node/src/streamableHttp.examples.ts | 56 + .../middleware/node/src/streamableHttp.ts | 204 + .../node/test/streamableHttp.test.ts | 3130 ++++++ packages/middleware/node/tsconfig.json | 15 + packages/middleware/node/tsdown.config.ts | 32 + packages/middleware/node/typedoc.json | 10 + packages/middleware/node/vitest.config.js | 3 + packages/server/CHANGELOG.md | 116 + packages/server/README.md | 27 + packages/server/eslint.config.mjs | 12 + packages/server/package.json | 108 + packages/server/src/experimental/index.ts | 13 + .../server/src/experimental/tasks/index.ts | 10 + .../src/experimental/tasks/interfaces.ts | 66 + .../src/experimental/tasks/mcpServer.ts | 139 + .../server/src/experimental/tasks/server.ts | 298 + packages/server/src/fromJsonSchema.ts | 9 + packages/server/src/index.ts | 52 + .../server/src/server/completable.examples.ts | 46 + packages/server/src/server/completable.ts | 74 + packages/server/src/server/mcp.examples.ts | 145 + packages/server/src/server/mcp.ts | 1397 +++ .../hostHeaderValidation.examples.ts | 20 + .../server/middleware/hostHeaderValidation.ts | 69 + packages/server/src/server/server.ts | 672 ++ packages/server/src/server/stdio.examples.ts | 22 + packages/server/src/server/stdio.ts | 138 + .../src/server/streamableHttp.examples.ts | 66 + packages/server/src/server/streamableHttp.ts | 1038 ++ packages/server/src/shimsNode.ts | 7 + packages/server/src/shimsWorkerd.ts | 23 + packages/server/src/stdio.ts | 8 + packages/server/src/validators/cfWorker.ts | 10 + .../server/test/server/barrelClean.test.ts | 56 + .../server/test/server/completable.test.ts | 56 + .../server/test/server/mcp.compat.test.ts | 129 + packages/server/test/server/server.test.ts | 42 + packages/server/test/server/stdio.test.ts | 181 + .../server/test/server/streamableHttp.test.ts | 996 ++ packages/server/tsconfig.json | 17 + packages/server/tsdown.config.ts | 40 + packages/server/typedoc.json | 10 + packages/server/vitest.config.js | 3 + pnpm-lock.yaml | 9175 +++++++++++++++++ pnpm-workspace.yaml | 69 + scripts/cli.ts | 155 + scripts/fetch-spec-types.ts | 90 + scripts/generate-multidoc.sh | 115 + scripts/sync-snippets.ts | 593 ++ test/conformance/README.md | 83 + test/conformance/eslint.config.mjs | 5 + test/conformance/expected-failures.yaml | 4 + test/conformance/package.json | 54 + .../scripts/run-server-conformance.sh | 45 + test/conformance/src/authTestServer.ts | 429 + test/conformance/src/everythingClient.ts | 496 + test/conformance/src/everythingServer.ts | 1026 ++ .../src/helpers/conformanceOAuthProvider.ts | 93 + test/conformance/src/helpers/logger.ts | 27 + .../conformance/src/helpers/withOAuthRetry.ts | 104 + test/conformance/tsconfig.json | 18 + test/conformance/vitest.config.js | 3 + test/helpers/eslint.config.mjs | 5 + test/helpers/package.json | 40 + test/helpers/src/helpers/http.ts | 96 + test/helpers/src/helpers/oauth.ts | 89 + test/helpers/src/helpers/tasks.ts | 33 + test/helpers/src/index.ts | 3 + test/helpers/tsconfig.json | 13 + test/helpers/vitest.config.js | 3 + test/integration/CHANGELOG.md | 11 + test/integration/eslint.config.mjs | 5 + test/integration/package.json | 55 + .../test/__fixtures__/serverThatHangs.ts | 43 + .../test/__fixtures__/testServer.ts | 20 + test/integration/test/client/client.test.ts | 4252 ++++++++ .../test/experimental/tasks/task.test.ts | 144 + .../experimental/tasks/taskListing.test.ts | 129 + test/integration/test/helpers/mcp.ts | 70 + .../test1277.zod.v4.description.test.ts | 61 + .../test400.optional-tool-params.test.ts | 64 + .../issues/test_1342OauthErrorHttp200.test.ts | 44 + test/integration/test/processCleanup.test.ts | 114 + test/integration/test/server.test.ts | 3808 +++++++ test/integration/test/server/bun.test.ts | 57 + .../test/server/cloudflareWorkers.test.ts | 177 + test/integration/test/server/deno.test.ts | 53 + .../test/server/elicitation.test.ts | 987 ++ test/integration/test/server/mcp.test.ts | 7042 +++++++++++++ test/integration/test/standardSchema.test.ts | 754 ++ .../stateManagementStreamableHttp.test.ts | 320 + test/integration/test/taskLifecycle.test.ts | 1625 +++ .../integration/test/taskResumability.test.ts | 300 + test/integration/test/title.test.ts | 227 + test/integration/tsconfig.json | 25 + test/integration/vitest.config.js | 11 + typedoc.config.mjs | 56 + vitest.workspace.js | 3 + 479 files changed, 112449 insertions(+) create mode 100644 .changeset/README.md create mode 100644 .changeset/abort-handlers-on-close.md create mode 100644 .changeset/add-fastify-middleware.md create mode 100644 .changeset/add-hono-peer-dep.md create mode 100644 .changeset/add-resource-size-field.md create mode 100644 .changeset/add-sdk-http-error.md create mode 100644 .changeset/brave-lions-glow.md create mode 100644 .changeset/busy-rice-smoke.md create mode 100644 .changeset/busy-weeks-hang.md create mode 100644 .changeset/cfworker-out-of-barrel.md create mode 100644 .changeset/config.json create mode 100644 .changeset/custom-methods-minimal.md create mode 100644 .changeset/cyan-cycles-pump.md create mode 100644 .changeset/drop-zod-peer-dep.md create mode 100644 .changeset/export-inmemory-transport.md create mode 100644 .changeset/expose-auth-server-discovery.md create mode 100644 .changeset/express-resource-server-auth.md create mode 100644 .changeset/extract-task-manager.md create mode 100644 .changeset/fast-dragons-lead.md create mode 100644 .changeset/finish-sdkerror-capability.md create mode 100644 .changeset/fix-abort-listener-leak.md create mode 100644 .changeset/fix-failed-task-result-retrieval.md create mode 100644 .changeset/fix-oauth-5xx-discovery.md create mode 100644 .changeset/fix-onerror-callbacks.md create mode 100644 .changeset/fix-server-protocol-version.md create mode 100644 .changeset/fix-session-status-codes.md create mode 100644 .changeset/fix-stdio-epipe-crash.md create mode 100644 .changeset/fix-stdio-windows-hide.md create mode 100644 .changeset/fix-streamable-close-reentrant.md create mode 100644 .changeset/fix-streamable-http-error-response.md create mode 100644 .changeset/fix-task-session-isolation.md create mode 100644 .changeset/fix-transport-exact-optional-property-types.md create mode 100644 .changeset/fix-unknown-tool-protocol-error.md create mode 100644 .changeset/fix-validate-client-metadata-url.md create mode 100644 .changeset/funky-baths-attack.md create mode 100644 .changeset/gentle-planets-rest.md create mode 100644 .changeset/heavy-walls-swim.md create mode 100644 .changeset/hono-peer-optional.md create mode 100644 .changeset/legacy-module-resolution-types.md create mode 100644 .changeset/oauth-error-http200.md create mode 100644 .changeset/odd-forks-enjoy.md create mode 100644 .changeset/pre.json create mode 100644 .changeset/quick-islands-occur.md create mode 100644 .changeset/reconnection-scheduler.md create mode 100644 .changeset/register-rawshape-compat.md create mode 100644 .changeset/remove-websocket-transport.md create mode 100644 .changeset/respect-capability-negotiation.md create mode 100644 .changeset/rich-hounds-report.md create mode 100644 .changeset/schema-object-type-for-unions.md create mode 100644 .changeset/shy-times-learn.md create mode 100644 .changeset/spec-type-schema.md create mode 100644 .changeset/spotty-cats-tickle.md create mode 100644 .changeset/stdio-skip-non-json.md create mode 100644 .changeset/stdio-subpath-export.md create mode 100644 .changeset/support-standard-json-schema.md create mode 100644 .changeset/tame-camels-greet.md create mode 100644 .changeset/tender-snails-fold.md create mode 100644 .changeset/token-provider-composable-auth.md create mode 100644 .changeset/twelve-dodos-taste.md create mode 100644 .changeset/use-scopes-supported-in-dcr.md create mode 100644 .changeset/wraphandler-hook.md create mode 100644 .changeset/zod-json-schema-compat.md create mode 100644 .changeset/zod-jsonschema-fallback.md create mode 100644 .git-blame-ignore-revs create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/conformance.yml create mode 100644 .github/workflows/deploy-docs.yml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/update-spec-types.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 CLAUDE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 REVIEW.md create mode 100644 SECURITY.md create mode 100644 common/eslint-config/eslint.config.mjs create mode 100644 common/eslint-config/package.json create mode 100644 common/tsconfig/package.json create mode 100644 common/tsconfig/tsconfig.json create mode 100644 common/vitest-config/package.json create mode 100644 common/vitest-config/tsconfig.json create mode 100644 common/vitest-config/vitest.config.js create mode 100644 docs/client-quickstart.md create mode 100644 docs/client.md create mode 100644 docs/documents.md create mode 100644 docs/faq.md create mode 100644 docs/migration-SKILL.md create mode 100644 docs/migration.md create mode 100644 docs/server-quickstart.md create mode 100644 docs/server.md create mode 100644 docs/v2-banner.js create mode 100644 examples/client-quickstart/.gitignore create mode 100644 examples/client-quickstart/package.json create mode 100644 examples/client-quickstart/src/index.ts create mode 100644 examples/client-quickstart/tsconfig.json create mode 100644 examples/client/README.md create mode 100644 examples/client/eslint.config.mjs create mode 100644 examples/client/package.json create mode 100644 examples/client/src/clientGuide.examples.ts create mode 100644 examples/client/src/customMethodExample.ts create mode 100644 examples/client/src/dualModeAuth.ts create mode 100644 examples/client/src/elicitationUrlExample.ts create mode 100644 examples/client/src/multipleClientsParallel.ts create mode 100644 examples/client/src/parallelToolCallsClient.ts create mode 100644 examples/client/src/simpleClientCredentials.ts create mode 100644 examples/client/src/simpleOAuthClient.ts create mode 100644 examples/client/src/simpleOAuthClientProvider.ts create mode 100644 examples/client/src/simpleStreamableHttp.ts create mode 100644 examples/client/src/simpleTaskInteractiveClient.ts create mode 100644 examples/client/src/simpleTokenProvider.ts create mode 100644 examples/client/src/ssePollingClient.ts create mode 100644 examples/client/src/streamableHttpWithSseFallbackClient.ts create mode 100644 examples/client/tsconfig.json create mode 100644 examples/client/tsdown.config.ts create mode 100644 examples/client/vitest.config.js create mode 100644 examples/server-quickstart/.gitignore create mode 100644 examples/server-quickstart/package.json create mode 100644 examples/server-quickstart/src/index.ts create mode 100644 examples/server-quickstart/tsconfig.json create mode 100644 examples/server/README.md create mode 100644 examples/server/eslint.config.mjs create mode 100644 examples/server/package.json create mode 100644 examples/server/src/README-simpleTaskInteractive.md create mode 100644 examples/server/src/arktypeExample.ts create mode 100644 examples/server/src/customMethodExample.ts create mode 100644 examples/server/src/customProtocolVersion.ts create mode 100644 examples/server/src/elicitationFormExample.ts create mode 100644 examples/server/src/elicitationUrlExample.ts create mode 100644 examples/server/src/honoWebStandardStreamableHttp.ts create mode 100644 examples/server/src/inMemoryEventStore.ts create mode 100644 examples/server/src/jsonResponseStreamableHttp.ts create mode 100644 examples/server/src/mcpServerOutputSchema.ts create mode 100644 examples/server/src/resourceServerOnly.ts create mode 100644 examples/server/src/serverGuide.examples.ts create mode 100644 examples/server/src/simpleStatelessStreamableHttp.ts create mode 100644 examples/server/src/simpleStreamableHttp.ts create mode 100644 examples/server/src/simpleTaskInteractive.ts create mode 100644 examples/server/src/ssePollingExample.ts create mode 100644 examples/server/src/standaloneSseWithGetStreamableHttp.ts create mode 100644 examples/server/src/toolWithSampleServer.ts create mode 100644 examples/server/src/valibotExample.ts create mode 100644 examples/server/tsconfig.json create mode 100644 examples/server/tsdown.config.ts create mode 100644 examples/server/vitest.config.js create mode 100644 examples/shared/eslint.config.mjs create mode 100644 examples/shared/package.json create mode 100644 examples/shared/src/auth.ts create mode 100644 examples/shared/src/authServer.ts create mode 100644 examples/shared/src/index.ts create mode 100644 examples/shared/test/demoInMemoryOAuthProvider.test.ts create mode 100644 examples/shared/tsconfig.json create mode 100644 examples/shared/vitest.config.js create mode 100644 lefthook-local.example.yml create mode 100644 lefthook.yml create mode 100644 package.json create mode 100644 packages/client/CHANGELOG.md create mode 100644 packages/client/README.md create mode 100644 packages/client/eslint.config.mjs create mode 100644 packages/client/package.json create mode 100644 packages/client/src/client/auth.examples.ts create mode 100644 packages/client/src/client/auth.ts create mode 100644 packages/client/src/client/authExtensions.examples.ts create mode 100644 packages/client/src/client/authExtensions.ts create mode 100644 packages/client/src/client/client.examples.ts create mode 100644 packages/client/src/client/client.ts create mode 100644 packages/client/src/client/crossAppAccess.ts create mode 100644 packages/client/src/client/middleware.examples.ts create mode 100644 packages/client/src/client/middleware.ts create mode 100644 packages/client/src/client/sse.ts create mode 100644 packages/client/src/client/stdio.ts create mode 100644 packages/client/src/client/streamableHttp.examples.ts create mode 100644 packages/client/src/client/streamableHttp.ts create mode 100644 packages/client/src/experimental/index.ts create mode 100644 packages/client/src/experimental/tasks/client.examples.ts create mode 100644 packages/client/src/experimental/tasks/client.ts create mode 100644 packages/client/src/fromJsonSchema.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/shimsBrowser.ts create mode 100644 packages/client/src/shimsNode.ts create mode 100644 packages/client/src/shimsWorkerd.ts create mode 100644 packages/client/src/stdio.ts create mode 100644 packages/client/src/validators/cfWorker.ts create mode 100644 packages/client/test/client/auth.test.ts create mode 100644 packages/client/test/client/authExtensions.test.ts create mode 100644 packages/client/test/client/barrelClean.test.ts create mode 100644 packages/client/test/client/crossAppAccess.test.ts create mode 100644 packages/client/test/client/crossSpawn.test.ts create mode 100644 packages/client/test/client/middleware.test.ts create mode 100644 packages/client/test/client/sse.test.ts create mode 100644 packages/client/test/client/stdio.test.ts create mode 100644 packages/client/test/client/streamableHttp.test.ts create mode 100644 packages/client/test/client/tokenProvider.test.ts create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/tsdown.config.ts create mode 100644 packages/client/typedoc.json create mode 100644 packages/client/vitest.config.js create mode 100644 packages/client/vitest.setup.js create mode 100644 packages/codemod/batch-test/.gitignore create mode 100644 packages/codemod/batch-test/README.md create mode 100644 packages/codemod/batch-test/analyze-prompt.md create mode 100644 packages/codemod/batch-test/repos.json create mode 100644 packages/codemod/eslint.config.mjs create mode 100644 packages/codemod/package.json create mode 100644 packages/codemod/scripts/generateSpecSchemaMap.ts create mode 100644 packages/codemod/scripts/generateVersions.ts create mode 100644 packages/codemod/src/bin/batchTest.ts create mode 100644 packages/codemod/src/cli.ts create mode 100644 packages/codemod/src/generated/specSchemaMap.ts create mode 100644 packages/codemod/src/generated/versions.ts create mode 100644 packages/codemod/src/index.ts create mode 100644 packages/codemod/src/migrations/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/index.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts create mode 100644 packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts create mode 100644 packages/codemod/src/runner.ts create mode 100644 packages/codemod/src/types.ts create mode 100644 packages/codemod/src/utils/astUtils.ts create mode 100644 packages/codemod/src/utils/diagnostics.ts create mode 100644 packages/codemod/src/utils/importUtils.ts create mode 100644 packages/codemod/src/utils/packageJsonUpdater.ts create mode 100644 packages/codemod/src/utils/projectAnalyzer.ts create mode 100644 packages/codemod/test/cli.test.ts create mode 100644 packages/codemod/test/integration.test.ts create mode 100644 packages/codemod/test/packageJsonUpdater.test.ts create mode 100644 packages/codemod/test/projectAnalyzer.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts create mode 100644 packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts create mode 100644 packages/codemod/tsconfig.json create mode 100644 packages/codemod/tsdown.config.ts create mode 100644 packages/codemod/typedoc.json create mode 100644 packages/codemod/vitest.config.js create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/core/eslint.config.mjs create mode 100644 packages/core/package.json create mode 100644 packages/core/src/auth/errors.ts create mode 100644 packages/core/src/errors/sdkErrors.examples.ts create mode 100644 packages/core/src/errors/sdkErrors.ts create mode 100644 packages/core/src/experimental/index.ts create mode 100644 packages/core/src/experimental/tasks/helpers.ts create mode 100644 packages/core/src/experimental/tasks/interfaces.ts create mode 100644 packages/core/src/experimental/tasks/stores/inMemory.ts create mode 100644 packages/core/src/exports/public/index.ts create mode 100644 packages/core/src/exports/types/index.ts create mode 100644 packages/core/src/index.examples.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/shared/auth.ts create mode 100644 packages/core/src/shared/authUtils.ts create mode 100644 packages/core/src/shared/metadataUtils.ts create mode 100644 packages/core/src/shared/protocol.examples.ts create mode 100644 packages/core/src/shared/protocol.ts create mode 100644 packages/core/src/shared/responseMessage.ts create mode 100644 packages/core/src/shared/stdio.ts create mode 100644 packages/core/src/shared/taskManager.ts create mode 100644 packages/core/src/shared/toolNameValidation.ts create mode 100644 packages/core/src/shared/transport.ts create mode 100644 packages/core/src/shared/uriTemplate.ts create mode 100644 packages/core/src/types/constants.ts create mode 100644 packages/core/src/types/enums.ts create mode 100644 packages/core/src/types/errors.ts create mode 100644 packages/core/src/types/guards.ts create mode 100644 packages/core/src/types/index.ts create mode 100644 packages/core/src/types/schemas.ts create mode 100644 packages/core/src/types/spec.types.ts create mode 100644 packages/core/src/types/specTypeSchema.examples.ts create mode 100644 packages/core/src/types/specTypeSchema.ts create mode 100644 packages/core/src/types/types.ts create mode 100644 packages/core/src/util/inMemory.ts create mode 100644 packages/core/src/util/schema.ts create mode 100644 packages/core/src/util/standardSchema.ts create mode 100644 packages/core/src/util/zodCompat.ts create mode 100644 packages/core/src/validators/ajvProvider.examples.ts create mode 100644 packages/core/src/validators/ajvProvider.ts create mode 100644 packages/core/src/validators/cfWorkerProvider.examples.ts create mode 100644 packages/core/src/validators/cfWorkerProvider.ts create mode 100644 packages/core/src/validators/fromJsonSchema.examples.ts create mode 100644 packages/core/src/validators/fromJsonSchema.ts create mode 100644 packages/core/src/validators/types.examples.ts create mode 100644 packages/core/src/validators/types.ts create mode 100644 packages/core/test/experimental/inMemory.test.ts create mode 100644 packages/core/test/inMemory.test.ts create mode 100644 packages/core/test/shared/auth.test.ts create mode 100644 packages/core/test/shared/authUtils.test.ts create mode 100644 packages/core/test/shared/customMethods.test.ts create mode 100644 packages/core/test/shared/protocol.test.ts create mode 100644 packages/core/test/shared/protocolTransportHandling.test.ts create mode 100644 packages/core/test/shared/stdio.test.ts create mode 100644 packages/core/test/shared/toolNameValidation.test.ts create mode 100644 packages/core/test/shared/transport.test.ts create mode 100644 packages/core/test/shared/uriTemplate.test.ts create mode 100644 packages/core/test/shared/wrapHandler.test.ts create mode 100644 packages/core/test/spec.types.test.ts create mode 100644 packages/core/test/types.capabilities.test.ts create mode 100644 packages/core/test/types.test.ts create mode 100644 packages/core/test/types/guards.test.ts create mode 100644 packages/core/test/types/specTypeSchema.test.ts create mode 100644 packages/core/test/util/standardSchema.test.ts create mode 100644 packages/core/test/util/standardSchema.zodFallback.test.ts create mode 100644 packages/core/test/util/zodCompat.test.ts create mode 100644 packages/core/test/validators/validators.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.js create mode 100644 packages/middleware/README.md create mode 100644 packages/middleware/express/CHANGELOG.md create mode 100644 packages/middleware/express/README.md create mode 100644 packages/middleware/express/eslint.config.mjs create mode 100644 packages/middleware/express/package.json create mode 100644 packages/middleware/express/src/auth/bearerAuth.ts create mode 100644 packages/middleware/express/src/auth/metadataRouter.ts create mode 100644 packages/middleware/express/src/auth/types.ts create mode 100644 packages/middleware/express/src/express.examples.ts create mode 100644 packages/middleware/express/src/express.ts create mode 100644 packages/middleware/express/src/index.ts create mode 100644 packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts create mode 100644 packages/middleware/express/src/middleware/hostHeaderValidation.ts create mode 100644 packages/middleware/express/test/auth/resourceServer.test.ts create mode 100644 packages/middleware/express/test/express.test.ts create mode 100644 packages/middleware/express/tsconfig.json create mode 100644 packages/middleware/express/tsdown.config.ts create mode 100644 packages/middleware/express/typedoc.json create mode 100644 packages/middleware/express/vitest.config.js create mode 100644 packages/middleware/fastify/CHANGELOG.md create mode 100644 packages/middleware/fastify/README.md create mode 100644 packages/middleware/fastify/eslint.config.mjs create mode 100644 packages/middleware/fastify/package.json create mode 100644 packages/middleware/fastify/src/fastify.examples.ts create mode 100644 packages/middleware/fastify/src/fastify.ts create mode 100644 packages/middleware/fastify/src/index.ts create mode 100644 packages/middleware/fastify/src/middleware/hostHeaderValidation.examples.ts create mode 100644 packages/middleware/fastify/src/middleware/hostHeaderValidation.ts create mode 100644 packages/middleware/fastify/test/fastify.test.ts create mode 100644 packages/middleware/fastify/tsconfig.json create mode 100644 packages/middleware/fastify/tsdown.config.ts create mode 100644 packages/middleware/fastify/typedoc.json create mode 100644 packages/middleware/fastify/vitest.config.js create mode 100644 packages/middleware/hono/CHANGELOG.md create mode 100644 packages/middleware/hono/README.md create mode 100644 packages/middleware/hono/eslint.config.mjs create mode 100644 packages/middleware/hono/package.json create mode 100644 packages/middleware/hono/src/hono.ts create mode 100644 packages/middleware/hono/src/index.ts create mode 100644 packages/middleware/hono/src/middleware/hostHeaderValidation.ts create mode 100644 packages/middleware/hono/test/hono.test.ts create mode 100644 packages/middleware/hono/tsconfig.json create mode 100644 packages/middleware/hono/tsdown.config.ts create mode 100644 packages/middleware/hono/typedoc.json create mode 100644 packages/middleware/hono/vitest.config.js create mode 100644 packages/middleware/node/CHANGELOG.md create mode 100644 packages/middleware/node/README.md create mode 100644 packages/middleware/node/eslint.config.mjs create mode 100644 packages/middleware/node/package.json create mode 100644 packages/middleware/node/src/index.ts create mode 100644 packages/middleware/node/src/streamableHttp.examples.ts create mode 100644 packages/middleware/node/src/streamableHttp.ts create mode 100644 packages/middleware/node/test/streamableHttp.test.ts create mode 100644 packages/middleware/node/tsconfig.json create mode 100644 packages/middleware/node/tsdown.config.ts create mode 100644 packages/middleware/node/typedoc.json create mode 100644 packages/middleware/node/vitest.config.js create mode 100644 packages/server/CHANGELOG.md create mode 100644 packages/server/README.md create mode 100644 packages/server/eslint.config.mjs create mode 100644 packages/server/package.json create mode 100644 packages/server/src/experimental/index.ts create mode 100644 packages/server/src/experimental/tasks/index.ts create mode 100644 packages/server/src/experimental/tasks/interfaces.ts create mode 100644 packages/server/src/experimental/tasks/mcpServer.ts create mode 100644 packages/server/src/experimental/tasks/server.ts create mode 100644 packages/server/src/fromJsonSchema.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/server/completable.examples.ts create mode 100644 packages/server/src/server/completable.ts create mode 100644 packages/server/src/server/mcp.examples.ts create mode 100644 packages/server/src/server/mcp.ts create mode 100644 packages/server/src/server/middleware/hostHeaderValidation.examples.ts create mode 100644 packages/server/src/server/middleware/hostHeaderValidation.ts create mode 100644 packages/server/src/server/server.ts create mode 100644 packages/server/src/server/stdio.examples.ts create mode 100644 packages/server/src/server/stdio.ts create mode 100644 packages/server/src/server/streamableHttp.examples.ts create mode 100644 packages/server/src/server/streamableHttp.ts create mode 100644 packages/server/src/shimsNode.ts create mode 100644 packages/server/src/shimsWorkerd.ts create mode 100644 packages/server/src/stdio.ts create mode 100644 packages/server/src/validators/cfWorker.ts create mode 100644 packages/server/test/server/barrelClean.test.ts create mode 100644 packages/server/test/server/completable.test.ts create mode 100644 packages/server/test/server/mcp.compat.test.ts create mode 100644 packages/server/test/server/server.test.ts create mode 100644 packages/server/test/server/stdio.test.ts create mode 100644 packages/server/test/server/streamableHttp.test.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/server/tsdown.config.ts create mode 100644 packages/server/typedoc.json create mode 100644 packages/server/vitest.config.js create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/cli.ts create mode 100644 scripts/fetch-spec-types.ts create mode 100644 scripts/generate-multidoc.sh create mode 100644 scripts/sync-snippets.ts create mode 100644 test/conformance/README.md create mode 100644 test/conformance/eslint.config.mjs create mode 100644 test/conformance/expected-failures.yaml create mode 100644 test/conformance/package.json create mode 100644 test/conformance/scripts/run-server-conformance.sh create mode 100644 test/conformance/src/authTestServer.ts create mode 100644 test/conformance/src/everythingClient.ts create mode 100644 test/conformance/src/everythingServer.ts create mode 100644 test/conformance/src/helpers/conformanceOAuthProvider.ts create mode 100644 test/conformance/src/helpers/logger.ts create mode 100644 test/conformance/src/helpers/withOAuthRetry.ts create mode 100644 test/conformance/tsconfig.json create mode 100644 test/conformance/vitest.config.js create mode 100644 test/helpers/eslint.config.mjs create mode 100644 test/helpers/package.json create mode 100644 test/helpers/src/helpers/http.ts create mode 100644 test/helpers/src/helpers/oauth.ts create mode 100644 test/helpers/src/helpers/tasks.ts create mode 100644 test/helpers/src/index.ts create mode 100644 test/helpers/tsconfig.json create mode 100644 test/helpers/vitest.config.js create mode 100644 test/integration/CHANGELOG.md create mode 100644 test/integration/eslint.config.mjs create mode 100644 test/integration/package.json create mode 100644 test/integration/test/__fixtures__/serverThatHangs.ts create mode 100644 test/integration/test/__fixtures__/testServer.ts create mode 100644 test/integration/test/client/client.test.ts create mode 100644 test/integration/test/experimental/tasks/task.test.ts create mode 100644 test/integration/test/experimental/tasks/taskListing.test.ts create mode 100644 test/integration/test/helpers/mcp.ts create mode 100644 test/integration/test/issues/test1277.zod.v4.description.test.ts create mode 100644 test/integration/test/issues/test400.optional-tool-params.test.ts create mode 100644 test/integration/test/issues/test_1342OauthErrorHttp200.test.ts create mode 100644 test/integration/test/processCleanup.test.ts create mode 100644 test/integration/test/server.test.ts create mode 100644 test/integration/test/server/bun.test.ts create mode 100644 test/integration/test/server/cloudflareWorkers.test.ts create mode 100644 test/integration/test/server/deno.test.ts create mode 100644 test/integration/test/server/elicitation.test.ts create mode 100644 test/integration/test/server/mcp.test.ts create mode 100644 test/integration/test/standardSchema.test.ts create mode 100644 test/integration/test/stateManagementStreamableHttp.test.ts create mode 100644 test/integration/test/taskLifecycle.test.ts create mode 100644 test/integration/test/taskResumability.test.ts create mode 100644 test/integration/test/title.test.ts create mode 100644 test/integration/tsconfig.json create mode 100644 test/integration/vitest.config.js create mode 100644 typedoc.config.mjs create mode 100644 vitest.workspace.js diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..8385205 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,6 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it +[in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/abort-handlers-on-close.md b/.changeset/abort-handlers-on-close.md new file mode 100644 index 0000000..b6bc65e --- /dev/null +++ b/.changeset/abort-handlers-on-close.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Abort in-flight request handlers when the connection closes. Previously, request handlers would continue running after the transport disconnected, wasting resources and preventing proper cleanup. Also fixes `InMemoryTransport.close()` firing `onclose` twice on the initiating side. diff --git a/.changeset/add-fastify-middleware.md b/.changeset/add-fastify-middleware.md new file mode 100644 index 0000000..9a5cfbf --- /dev/null +++ b/.changeset/add-fastify-middleware.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/fastify': minor +--- + +Add Fastify middleware adapter for MCP servers, following the same pattern as the Express and Hono adapters. diff --git a/.changeset/add-hono-peer-dep.md b/.changeset/add-hono-peer-dep.md new file mode 100644 index 0000000..25f90bb --- /dev/null +++ b/.changeset/add-hono-peer-dep.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Add missing `hono` peer dependency to `@modelcontextprotocol/node`. The package already depends on `@hono/node-server` which requires `hono` at runtime, but `hono` was only listed in the workspace root, not as a peer dependency of the package itself. diff --git a/.changeset/add-resource-size-field.md b/.changeset/add-resource-size-field.md new file mode 100644 index 0000000..bef37cb --- /dev/null +++ b/.changeset/add-resource-size-field.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Add missing `size` field to `ResourceSchema` to match the MCP specification diff --git a/.changeset/add-sdk-http-error.md b/.changeset/add-sdk-http-error.md new file mode 100644 index 0000000..c3331a5 --- /dev/null +++ b/.changeset/add-sdk-http-error.md @@ -0,0 +1,6 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +--- + +Add `SdkHttpError` subclass with typed `.status` / `.statusText` accessors for HTTP transport failures. `StreamableHTTPClientTransport` now throws `SdkHttpError` (which extends `SdkError`) for non-OK HTTP responses; `SSEClientTransport` throws `SdkHttpError` for 401-after-reauth (circuit breaker). diff --git a/.changeset/brave-lions-glow.md b/.changeset/brave-lions-glow.md new file mode 100644 index 0000000..5871838 --- /dev/null +++ b/.changeset/brave-lions-glow.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Prevent Hono from overriding global Response object by passing `overrideGlobalObjects: false` to `getRequestListener()`. This fixes compatibility with frameworks like Next.js whose response classes extend the native Response. diff --git a/.changeset/busy-rice-smoke.md b/.changeset/busy-rice-smoke.md new file mode 100644 index 0000000..69badd8 --- /dev/null +++ b/.changeset/busy-rice-smoke.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +tasks - disallow requesting a null TTL diff --git a/.changeset/busy-weeks-hang.md b/.changeset/busy-weeks-hang.md new file mode 100644 index 0000000..a045aaa --- /dev/null +++ b/.changeset/busy-weeks-hang.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Fix ReDoS vulnerability in UriTemplate regex patterns (CVE-2026-0621) diff --git a/.changeset/cfworker-out-of-barrel.md b/.changeset/cfworker-out-of-barrel.md new file mode 100644 index 0000000..9a35b84 --- /dev/null +++ b/.changeset/cfworker-out-of-barrel.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Stop bundling `@cfworker/json-schema` into the main package barrel. Previously `CfWorkerJsonSchemaValidator` was re-exported from the core internal barrel, so tsdown inlined the `@cfworker/json-schema` dev dependency into every consumer's bundle even when it was never used. The validator is now reachable only via the `_shims` conditional (workerd/browser) and the explicit `@modelcontextprotocol/{server,client}/validators/cf-worker` subpath, so consumers that don't opt into it no longer ship that code. No public API change. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..eb43bdc --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "modelcontextprotocol/typescript-sdk" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@modelcontextprotocol/examples-client", + "@modelcontextprotocol/examples-client-quickstart", + "@modelcontextprotocol/examples-server", + "@modelcontextprotocol/examples-server-quickstart", + "@modelcontextprotocol/examples-shared" + ] +} diff --git a/.changeset/custom-methods-minimal.md b/.changeset/custom-methods-minimal.md new file mode 100644 index 0000000..f722f45 --- /dev/null +++ b/.changeset/custom-methods-minimal.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add custom (non-spec) method support: a 3-arg `setRequestHandler(method, schemas, handler)` / `setNotificationHandler(method, schemas, handler)` form for vendor-prefixed methods, and a `request(req, resultSchema)` overload (also on `ctx.mcpReq.send`) for typed custom-method results. Spec-method calls are unchanged. + +Response result-schema validation failure now rejects with `SdkError(InvalidResult)` instead of a raw `ZodError`. Adds `SdkErrorCode.InvalidResult`. diff --git a/.changeset/cyan-cycles-pump.md b/.changeset/cyan-cycles-pump.md new file mode 100644 index 0000000..0f2008a --- /dev/null +++ b/.changeset/cyan-cycles-pump.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +missing change for fix(client): replace body.cancel() with text() to prevent hanging diff --git a/.changeset/drop-zod-peer-dep.md b/.changeset/drop-zod-peer-dep.md new file mode 100644 index 0000000..c5e2832 --- /dev/null +++ b/.changeset/drop-zod-peer-dep.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Drop `zod` from `peerDependencies` (kept as direct dependency) + +Since Standard Schema support landed, `zod` is purely an internal runtime dependency used for protocol message parsing. User-facing schemas (`registerTool`, `registerPrompt`) accept any Standard Schema library. `zod` remains in `dependencies` and auto-installs; users no longer +need to install it alongside the SDK. diff --git a/.changeset/export-inmemory-transport.md b/.changeset/export-inmemory-transport.md new file mode 100644 index 0000000..cc1d8f7 --- /dev/null +++ b/.changeset/export-inmemory-transport.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Export `InMemoryTransport` for in-process testing. diff --git a/.changeset/expose-auth-server-discovery.md b/.changeset/expose-auth-server-discovery.md new file mode 100644 index 0000000..443dce8 --- /dev/null +++ b/.changeset/expose-auth-server-discovery.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `discoverOAuthServerInfo()` function and unified discovery state caching for OAuth + +- New `discoverOAuthServerInfo(serverUrl)` export that performs RFC 9728 protected resource metadata discovery followed by authorization server metadata discovery in a single call. Use this for operations like token refresh and revocation that need the authorization server URL outside of `auth()`. +- New `OAuthDiscoveryState` type and optional `OAuthClientProvider` methods `saveDiscoveryState()` / `discoveryState()` allow providers to persist all discovery results (auth server URL, resource metadata URL, resource metadata, auth server metadata) across sessions. This avoids redundant discovery requests and handles browser redirect scenarios where discovery state would otherwise be lost. +- New `'discovery'` scope for `invalidateCredentials()` to clear cached discovery state. +- New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`. diff --git a/.changeset/express-resource-server-auth.md b/.changeset/express-resource-server-auth.md new file mode 100644 index 0000000..a9e1622 --- /dev/null +++ b/.changeset/express-resource-server-auth.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/express': minor +--- + +Add OAuth Resource-Server glue to the Express adapter: `requireBearerAuth` middleware (token verification + RFC 6750 `WWW-Authenticate` challenges), `mcpAuthMetadataRouter` (serves RFC 9728 Protected Resource Metadata and mirrors RFC 8414 AS metadata at the resource origin), the `getOAuthProtectedResourceMetadataUrl` helper, and the `OAuthTokenVerifier` interface. These restore the v1 `src/server/auth` Resource-Server pieces as first-class v2 API so MCP servers can plug into an external Authorization Server with a few lines of Express wiring. diff --git a/.changeset/extract-task-manager.md b/.changeset/extract-task-manager.md new file mode 100644 index 0000000..6a72182 --- /dev/null +++ b/.changeset/extract-task-manager.md @@ -0,0 +1,10 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/client": minor +"@modelcontextprotocol/server": minor +--- + +refactor: extract task orchestration from Protocol into TaskManager + +**Breaking changes:** +- `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` diff --git a/.changeset/fast-dragons-lead.md b/.changeset/fast-dragons-lead.md new file mode 100644 index 0000000..731aaa3 --- /dev/null +++ b/.changeset/fast-dragons-lead.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/fastify': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +tsdown exports resolution fix diff --git a/.changeset/finish-sdkerror-capability.md b/.changeset/finish-sdkerror-capability.md new file mode 100644 index 0000000..f9145a5 --- /dev/null +++ b/.changeset/finish-sdkerror-capability.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Convert remaining capability-assertion throws to `SdkError(SdkErrorCode.CapabilityNotSupported, ...)`. Follow-up to #1454 which missed `Client.assertCapability()`, the task capability helpers in `experimental/tasks/helpers.ts`, and the sampling/elicitation capability checks in `experimental/tasks/server.ts`. diff --git a/.changeset/fix-abort-listener-leak.md b/.changeset/fix-abort-listener-leak.md new file mode 100644 index 0000000..f1dd316 --- /dev/null +++ b/.changeset/fix-abort-listener-leak.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Consolidate per-request cleanup in `_requestWithSchema` into a single `.finally()` block. This fixes an abort signal listener leak (listeners accumulated when a caller reused one `AbortSignal` across requests) and two cases where `_responseHandlers` entries leaked on send-failure paths. diff --git a/.changeset/fix-failed-task-result-retrieval.md b/.changeset/fix-failed-task-result-retrieval.md new file mode 100644 index 0000000..aa4e3e3 --- /dev/null +++ b/.changeset/fix-failed-task-result-retrieval.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Fix `requestStream` to call `tasks/result` for failed tasks instead of yielding a hardcoded `ProtocolError`. When a task reaches the `failed` terminal status, the stream now retrieves and yields the actual stored result (matching the behavior for `completed` tasks), as required by the spec. diff --git a/.changeset/fix-oauth-5xx-discovery.md b/.changeset/fix-oauth-5xx-discovery.md new file mode 100644 index 0000000..0b2e05a --- /dev/null +++ b/.changeset/fix-oauth-5xx-discovery.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Continue OAuth metadata discovery on 502 (Bad Gateway) responses, matching the existing behavior for 4xx. This fixes MCP servers behind reverse proxies that return 502 for path-aware metadata URLs. Other 5xx errors still throw to avoid retrying against overloaded servers. diff --git a/.changeset/fix-onerror-callbacks.md b/.changeset/fix-onerror-callbacks.md new file mode 100644 index 0000000..4ca4e72 --- /dev/null +++ b/.changeset/fix-onerror-callbacks.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Fix transport errors being silently swallowed by adding missing `onerror` callback invocations before all `createJsonErrorResponse` calls in `WebStandardStreamableHTTPServerTransport`. This ensures errors like parse failures, invalid headers, and session validation errors are properly reported via the `onerror` callback. diff --git a/.changeset/fix-server-protocol-version.md b/.changeset/fix-server-protocol-version.md new file mode 100644 index 0000000..0210092 --- /dev/null +++ b/.changeset/fix-server-protocol-version.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +fix(server): propagate negotiated protocol version to transport in _oninitialize diff --git a/.changeset/fix-session-status-codes.md b/.changeset/fix-session-status-codes.md new file mode 100644 index 0000000..ff2a264 --- /dev/null +++ b/.changeset/fix-session-status-codes.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/examples-server': patch +--- + +Example servers now return HTTP 404 (not 400) when a request includes an unknown session ID, so clients can correctly detect they need to start a new session. Requests missing a session ID entirely still return 400. diff --git a/.changeset/fix-stdio-epipe-crash.md b/.changeset/fix-stdio-epipe-crash.md new file mode 100644 index 0000000..456a8c2 --- /dev/null +++ b/.changeset/fix-stdio-epipe-crash.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Handle stdout errors (e.g. EPIPE) in `StdioServerTransport` gracefully instead of crashing. When the client disconnects abruptly, the transport now catches the stdout error, surfaces it via `onerror`, and closes. diff --git a/.changeset/fix-stdio-windows-hide.md b/.changeset/fix-stdio-windows-hide.md new file mode 100644 index 0000000..7b0db27 --- /dev/null +++ b/.changeset/fix-stdio-windows-hide.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Always set `windowsHide` when spawning stdio server processes on Windows, not just in Electron environments. Prevents unwanted console windows in non-Electron Windows applications. diff --git a/.changeset/fix-streamable-close-reentrant.md b/.changeset/fix-streamable-close-reentrant.md new file mode 100644 index 0000000..9f0bd76 --- /dev/null +++ b/.changeset/fix-streamable-close-reentrant.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Prevent stack overflow in StreamableHTTPServerTransport.close() with re-entrant guard diff --git a/.changeset/fix-streamable-http-error-response.md b/.changeset/fix-streamable-http-error-response.md new file mode 100644 index 0000000..1de5839 --- /dev/null +++ b/.changeset/fix-streamable-http-error-response.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix StreamableHTTPClientTransport to handle error responses in SSE streams diff --git a/.changeset/fix-task-session-isolation.md b/.changeset/fix-task-session-isolation.md new file mode 100644 index 0000000..7220673 --- /dev/null +++ b/.changeset/fix-task-session-isolation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Fix InMemoryTaskStore to enforce session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes. diff --git a/.changeset/fix-transport-exact-optional-property-types.md b/.changeset/fix-transport-exact-optional-property-types.md new file mode 100644 index 0000000..c3187db --- /dev/null +++ b/.changeset/fix-transport-exact-optional-property-types.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Add explicit `| undefined` to optional properties on the `Transport` interface and `TransportSendOptions` (`onclose`, `onerror`, `onmessage`, `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, `onresumptiontoken`). + +This fixes TS2420 errors for consumers using `exactOptionalPropertyTypes: true` without `skipLibCheck`, where the emitted `.d.ts` for implementing classes included `| undefined` but the interface did not. + +Workaround for older SDK versions: enable `skipLibCheck: true` in your tsconfig. diff --git a/.changeset/fix-unknown-tool-protocol-error.md b/.changeset/fix-unknown-tool-protocol-error.md new file mode 100644 index 0000000..086158b --- /dev/null +++ b/.changeset/fix-unknown-tool-protocol-error.md @@ -0,0 +1,15 @@ +--- +"@modelcontextprotocol/core": minor +"@modelcontextprotocol/server": major +--- + +Fix error handling for unknown tools and resources per MCP spec. + +**Tools:** Unknown or disabled tool calls now return JSON-RPC protocol errors with +code `-32602` (InvalidParams) instead of `CallToolResult` with `isError: true`. +Callers who checked `result.isError` for unknown tools should catch rejected promises instead. + +**Resources:** Unknown resource reads now return error code `-32002` (ResourceNotFound) +instead of `-32602` (InvalidParams). + +Added `ProtocolErrorCode.ResourceNotFound`. diff --git a/.changeset/fix-validate-client-metadata-url.md b/.changeset/fix-validate-client-metadata-url.md new file mode 100644 index 0000000..a460fca --- /dev/null +++ b/.changeset/fix-validate-client-metadata-url.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `validateClientMetadataUrl()` utility for early validation of `clientMetadataUrl` + +Exports a `validateClientMetadataUrl()` function that `OAuthClientProvider` implementations +can call in their constructors to fail fast on invalid URL-based client IDs, instead of +discovering the error deep in the auth flow. diff --git a/.changeset/funky-baths-attack.md b/.changeset/funky-baths-attack.md new file mode 100644 index 0000000..f65f126 --- /dev/null +++ b/.changeset/funky-baths-attack.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/test-integration': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core': patch +--- + +remove deprecated .tool, .prompt, .resource method signatures diff --git a/.changeset/gentle-planets-rest.md b/.changeset/gentle-planets-rest.md new file mode 100644 index 0000000..21255a6 --- /dev/null +++ b/.changeset/gentle-planets-rest.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Add `| undefined` to optional callback and function properties on `WebStandardStreamableHTTPServerTransportOptions` (`sessionIdGenerator`, `onsessioninitialized`, `onsessionclosed`) and corresponding private fields. + +This fixes TS2430 errors for consumers using `exactOptionalPropertyTypes: true` without `skipLibCheck`, where optional properties with function types need explicit `| undefined` to match their emitted declarations. diff --git a/.changeset/heavy-walls-swim.md b/.changeset/heavy-walls-swim.md new file mode 100644 index 0000000..7a09cda --- /dev/null +++ b/.changeset/heavy-walls-swim.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +reverting application/json in notifications diff --git a/.changeset/hono-peer-optional.md b/.changeset/hono-peer-optional.md new file mode 100644 index 0000000..2f6a3ef --- /dev/null +++ b/.changeset/hono-peer-optional.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Mark `hono` peer dependency as optional. `@modelcontextprotocol/node` only uses `getRequestListener` from `@hono/node-server` (Node HTTP ↔ Web Standard conversion), which does not require the `hono` framework at runtime. Consumers no longer need to install `hono` to use `NodeStreamableHTTPServerTransport`. Note: `@hono/node-server` itself still declares `hono` as a hard peer, so package managers may emit a warning; this is upstream and harmless for `getRequestListener`-only usage. diff --git a/.changeset/legacy-module-resolution-types.md b/.changeset/legacy-module-resolution-types.md new file mode 100644 index 0000000..c12cb83 --- /dev/null +++ b/.changeset/legacy-module-resolution-types.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/fastify': patch +'@modelcontextprotocol/hono': patch +--- + +Add top-level `types` field (and `typesVersions` on client/server for their subpath exports) so consumers on legacy `moduleResolution: "node"` can resolve type declarations. The `exports` map remains the source of truth for `nodenext`/`bundler` resolution. The `typesVersions` map includes entries for subpaths added by sibling PRs in this series (`zod-schemas`, `stdio`); those entries are no-ops until the corresponding `dist/*.d.mts` files exist. diff --git a/.changeset/oauth-error-http200.md b/.changeset/oauth-error-http200.md new file mode 100644 index 0000000..1ce4fdd --- /dev/null +++ b/.changeset/oauth-error-http200.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix OAuth error handling for servers returning errors with HTTP 200 status + +Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status instead of 4xx. The SDK now checks for an `error` field in the JSON response before attempting to parse it as tokens, providing users with meaningful error messages. diff --git a/.changeset/odd-forks-enjoy.md b/.changeset/odd-forks-enjoy.md new file mode 100644 index 0000000..b1f57dc --- /dev/null +++ b/.changeset/odd-forks-enjoy.md @@ -0,0 +1,7 @@ +--- +"@modelcontextprotocol/client": patch +--- + +fix(client): append custom Accept headers to spec-required defaults in StreamableHTTPClientTransport + +Custom Accept headers provided via `requestInit.headers` are now appended to the spec-mandated Accept types instead of being overwritten. This ensures the required media types (`application/json, text/event-stream` for POST; `text/event-stream` for GET SSE) are always present while allowing users to include additional types for proxy/gateway routing. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..16ad435 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,68 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "@modelcontextprotocol/eslint-config": "2.0.0", + "@modelcontextprotocol/tsconfig": "2.0.0", + "@modelcontextprotocol/vitest-config": "2.0.0", + "@modelcontextprotocol/examples-client": "2.0.0-alpha.0", + "@modelcontextprotocol/examples-client-quickstart": "2.0.0-alpha.0", + "@modelcontextprotocol/examples-server": "2.0.0-alpha.0", + "@modelcontextprotocol/examples-server-quickstart": "2.0.0-alpha.0", + "@modelcontextprotocol/examples-shared": "2.0.0-alpha.0", + "@modelcontextprotocol/client": "2.0.0-alpha.0", + "@modelcontextprotocol/core": "2.0.0-alpha.0", + "@modelcontextprotocol/express": "2.0.0-alpha.0", + "@modelcontextprotocol/fastify": "2.0.0-alpha.0", + "@modelcontextprotocol/hono": "2.0.0-alpha.0", + "@modelcontextprotocol/node": "2.0.0-alpha.0", + "@modelcontextprotocol/server": "2.0.0-alpha.0", + "@modelcontextprotocol/test-conformance": "2.0.0-alpha.0", + "@modelcontextprotocol/test-helpers": "2.0.0-alpha.0", + "@modelcontextprotocol/test-integration": "2.0.0-alpha.0" + }, + "changesets": [ + "abort-handlers-on-close", + "add-fastify-middleware", + "add-hono-peer-dep", + "add-resource-size-field", + "brave-lions-glow", + "busy-rice-smoke", + "busy-weeks-hang", + "cyan-cycles-pump", + "drop-zod-peer-dep", + "expose-auth-server-discovery", + "extract-task-manager", + "fast-dragons-lead", + "finish-sdkerror-capability", + "fix-abort-listener-leak", + "fix-oauth-5xx-discovery", + "fix-onerror-callbacks", + "fix-server-protocol-version", + "fix-session-status-codes", + "fix-stdio-epipe-crash", + "fix-stdio-windows-hide", + "fix-streamable-http-error-response", + "fix-task-session-isolation", + "fix-transport-exact-optional-property-types", + "fix-unknown-tool-protocol-error", + "funky-baths-attack", + "heavy-walls-swim", + "oauth-error-http200", + "quick-islands-occur", + "reconnection-scheduler", + "remove-websocket-transport", + "respect-capability-negotiation", + "rich-hounds-report", + "schema-object-type-for-unions", + "shy-times-learn", + "spotty-cats-tickle", + "stdio-skip-non-json", + "support-standard-json-schema", + "tame-camels-greet", + "tender-snails-fold", + "token-provider-composable-auth", + "twelve-dodos-taste", + "use-scopes-supported-in-dcr" + ] +} diff --git a/.changeset/quick-islands-occur.md b/.changeset/quick-islands-occur.md new file mode 100644 index 0000000..2ec8390 --- /dev/null +++ b/.changeset/quick-islands-occur.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core': patch +--- + +remove npm references, use pnpm diff --git a/.changeset/reconnection-scheduler.md b/.changeset/reconnection-scheduler.md new file mode 100644 index 0000000..add9bd6 --- /dev/null +++ b/.changeset/reconnection-scheduler.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `reconnectionScheduler` option to `StreamableHTTPClientTransport`. Lets non-persistent environments (serverless, mobile, desktop sleep/wake) override the default `setTimeout`-based SSE reconnection scheduling. The scheduler may return a cancel function that is invoked on `transport.close()`. diff --git a/.changeset/register-rawshape-compat.md b/.changeset/register-rawshape-compat.md new file mode 100644 index 0000000..5f1f167 --- /dev/null +++ b/.changeset/register-rawshape-compat.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +`registerTool`/`registerPrompt` accept a raw Zod shape (`{ field: z.string() }`) for `inputSchema`/`outputSchema`/`argsSchema` in addition to a wrapped Standard Schema. Raw shapes are auto-wrapped with `z.object()`. The raw-shape overloads are `@deprecated`; prefer wrapping with `z.object()`. + +Also widens the `completable()` constraint from `StandardSchemaWithJSON` to `StandardSchemaV1` so v1's `completable(z.string(), fn)` continues to work. diff --git a/.changeset/remove-websocket-transport.md b/.changeset/remove-websocket-transport.md new file mode 100644 index 0000000..a36102d --- /dev/null +++ b/.changeset/remove-websocket-transport.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': major +--- + +Remove `WebSocketClientTransport`. WebSocket is not a spec-defined transport; use stdio or Streamable HTTP. The `Transport` interface remains exported for custom implementations. See #142. diff --git a/.changeset/respect-capability-negotiation.md b/.changeset/respect-capability-negotiation.md new file mode 100644 index 0000000..6a42cf6 --- /dev/null +++ b/.changeset/respect-capability-negotiation.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Respect capability negotiation in list methods by returning empty lists when server lacks capability + +The Client now returns empty lists instead of sending requests to servers that don't advertise the corresponding capability: + +- `listPrompts()` returns `{ prompts: [] }` if server lacks prompts capability +- `listResources()` returns `{ resources: [] }` if server lacks resources capability +- `listResourceTemplates()` returns `{ resourceTemplates: [] }` if server lacks resources capability +- `listTools()` returns `{ tools: [] }` if server lacks tools capability + +This respects the MCP spec requirement that "Both parties SHOULD respect capability negotiation" and avoids unnecessary server warnings and traffic. The existing `enforceStrictCapabilities` option continues to throw errors when set to `true`. diff --git a/.changeset/rich-hounds-report.md b/.changeset/rich-hounds-report.md new file mode 100644 index 0000000..d1736bf --- /dev/null +++ b/.changeset/rich-hounds-report.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/hono': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core': patch +--- + +clean up package manager usage, all pnpm diff --git a/.changeset/schema-object-type-for-unions.md b/.changeset/schema-object-type-for-unions.md new file mode 100644 index 0000000..7749bb6 --- /dev/null +++ b/.changeset/schema-object-type-for-unions.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Ensure `standardSchemaToJsonSchema` emits `type: "object"` at the root, fixing discriminated-union tool/prompt schemas that previously produced `{oneOf: [...]}` without the MCP-required top-level type. Also throws a clear error when given an explicitly non-object schema (e.g. `z.string()`). Fixes #1643. diff --git a/.changeset/shy-times-learn.md b/.changeset/shy-times-learn.md new file mode 100644 index 0000000..99617f8 --- /dev/null +++ b/.changeset/shy-times-learn.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/test-integration': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core': patch +--- + +deprecated .tool, .prompt, .resource method removal diff --git a/.changeset/spec-type-schema.md b/.changeset/spec-type-schema.md new file mode 100644 index 0000000..7bcc977 --- /dev/null +++ b/.changeset/spec-type-schema.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Export `isSpecType` and `specTypeSchemas` records for runtime validation of any MCP spec type by name. `isSpecType.ContentBlock(value)` is a type predicate; `specTypeSchemas.ContentBlock` is a `StandardSchemaV1Sync` validator — `validate()` returns the result synchronously. Guards are standalone functions, so `arr.filter(isSpecType.ContentBlock)` works. Also export the `SpecTypeName`, `SpecTypes`, and `StandardSchemaV1Sync` types. diff --git a/.changeset/spotty-cats-tickle.md b/.changeset/spotty-cats-tickle.md new file mode 100644 index 0000000..502130d --- /dev/null +++ b/.changeset/spotty-cats-tickle.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +The client credentials providers now support scopes being added to the token request. diff --git a/.changeset/stdio-skip-non-json.md b/.changeset/stdio-skip-non-json.md new file mode 100644 index 0000000..d20b740 --- /dev/null +++ b/.changeset/stdio-skip-non-json.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +`ReadBuffer.readMessage()` now silently skips non-JSON lines instead of throwing `SyntaxError`. This prevents noisy `onerror` callbacks when hot-reload tools (tsx, nodemon) write debug output like "Gracefully restarting..." to stdout. Lines that parse as JSON but fail JSONRPC schema validation still throw. diff --git a/.changeset/stdio-subpath-export.md b/.changeset/stdio-subpath-export.md new file mode 100644 index 0000000..b089319 --- /dev/null +++ b/.changeset/stdio-subpath-export.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Move stdio transports to a `./stdio` subpath export. Import `StdioClientTransport`, `getDefaultEnvironment`, `DEFAULT_INHERITED_ENV_VARS`, and `StdioServerParameters` from `@modelcontextprotocol/client/stdio`, and `StdioServerTransport` from `@modelcontextprotocol/server/stdio`. The `@modelcontextprotocol/client` root entry no longer pulls in `node:child_process`, `node:stream`, or `cross-spawn`, fixing bundling for browser and Cloudflare Workers targets; the `@modelcontextprotocol/server` root entry drops its `node:stream` reference. Node.js, Bun, and Deno consumers update the import path; runtime behavior is unchanged. diff --git a/.changeset/support-standard-json-schema.md b/.changeset/support-standard-json-schema.md new file mode 100644 index 0000000..1ceff35 --- /dev/null +++ b/.changeset/support-standard-json-schema.md @@ -0,0 +1,34 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +--- + +Support Standard Schema for tool and prompt schemas + +Tool and prompt registration now accepts any schema library that implements the [Standard Schema spec](https://standardschema.dev/): Zod v4, Valibot, ArkType, and others. `RegisteredTool.inputSchema`, `RegisteredTool.outputSchema`, and `RegisteredPrompt.argsSchema` now use `StandardSchemaWithJSON` (requires both `~standard.validate` and `~standard.jsonSchema`) instead of the Zod-specific `AnySchema` type. + +**Zod v4 schemas continue to work unchanged** — Zod v4 implements the required interfaces natively. + +```typescript +import { type } from 'arktype'; + +server.registerTool('greet', { + inputSchema: type({ name: 'string' }) +}, async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] })); +``` + +For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter: + +```typescript +import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core'; + +server.registerTool('greet', { + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator()) +}, handler); +``` + +**Breaking changes:** +- `experimental.tasks.getTaskResult()` no longer accepts a `resultSchema` parameter. Returns `GetTaskPayloadResult` (a loose `Result`); cast to the expected type at the call site. +- Removed unused exports from `@modelcontextprotocol/core`: `SchemaInput`, `schemaToJson`, `parseSchemaAsync`, `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema`. Use the new `standardSchemaToJsonSchema` and `validateStandardSchema` instead. +- `completable()` remains Zod-specific (it relies on Zod's `.shape` introspection). diff --git a/.changeset/tame-camels-greet.md b/.changeset/tame-camels-greet.md new file mode 100644 index 0000000..5f9c1d1 --- /dev/null +++ b/.changeset/tame-camels-greet.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Don't swallow fetch `TypeError` as CORS in non-browser environments. Network errors +(DNS resolution failure, connection refused, invalid URL) in Node.js and Cloudflare +Workers now propagate from OAuth discovery instead of being silently misattributed +to CORS and returning `undefined`. This surfaces the real error to callers rather +than masking it as "metadata not found." diff --git a/.changeset/tender-snails-fold.md b/.changeset/tender-snails-fold.md new file mode 100644 index 0000000..1385969 --- /dev/null +++ b/.changeset/tender-snails-fold.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Initial 2.0.0-alpha.0 client and server package diff --git a/.changeset/token-provider-composable-auth.md b/.changeset/token-provider-composable-auth.md new file mode 100644 index 0000000..f5c064e --- /dev/null +++ b/.changeset/token-provider-composable-auth.md @@ -0,0 +1,16 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Add `AuthProvider` for composable bearer-token auth; transports adapt `OAuthClientProvider` automatically + +- New `AuthProvider` interface: `{ token(): Promise; onUnauthorized?(ctx): Promise }`. Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry once). +- Transport `authProvider` option now accepts `AuthProvider | OAuthClientProvider`. OAuth providers are adapted internally via `adaptOAuthProvider()` — no changes needed to existing `OAuthClientProvider` implementations. +- For simple bearer tokens (API keys, gateway-managed tokens, service accounts): `{ authProvider: { token: async () => myKey } }` — one-line object literal, no class. +- New `adaptOAuthProvider(provider)` export for explicit adaptation. +- New `handleOAuthUnauthorized(provider, ctx)` helper — the standard OAuth `onUnauthorized` behavior. +- New `isOAuthClientProvider()` type guard. +- New `UnauthorizedContext` type. +- Exported previously-internal auth helpers for building custom flows: `applyBasicAuth`, `applyPostAuth`, `applyPublicAuth`, `executeTokenRequest`. + +Transports are simplified internally — ~50 lines of inline OAuth orchestration (auth() calls, WWW-Authenticate parsing, circuit-breaker state) moved into the adapter's `onUnauthorized()` implementation. `OAuthClientProvider` itself is unchanged. diff --git a/.changeset/twelve-dodos-taste.md b/.changeset/twelve-dodos-taste.md new file mode 100644 index 0000000..1b0fdc1 --- /dev/null +++ b/.changeset/twelve-dodos-taste.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/express": patch +--- + +Add jsonLimit option to createMcpExpressApp diff --git a/.changeset/use-scopes-supported-in-dcr.md b/.changeset/use-scopes-supported-in-dcr.md new file mode 100644 index 0000000..d40da05 --- /dev/null +++ b/.changeset/use-scopes-supported-in-dcr.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +--- + +Apply resolved scope consistently to both DCR and the authorization URL (SEP-835) + +When `scopes_supported` is present in the protected resource metadata (`/.well-known/oauth-protected-resource`), the SDK already uses it as the default scope for the authorization URL. This change applies the same resolved scope to the dynamic client registration request body, ensuring both use a consistent value. + +- `registerClient()` now accepts an optional `scope` parameter that overrides `clientMetadata.scope` in the registration body. +- `auth()` now computes the resolved scope once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes it to both DCR and the authorization request. diff --git a/.changeset/wraphandler-hook.md b/.changeset/wraphandler-hook.md new file mode 100644 index 0000000..935f576 --- /dev/null +++ b/.changeset/wraphandler-hook.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +refactor: subclasses override `_wrapHandler` hook instead of redeclaring `setRequestHandler`. diff --git a/.changeset/zod-json-schema-compat.md b/.changeset/zod-json-schema-compat.md new file mode 100644 index 0000000..5ca1470 --- /dev/null +++ b/.changeset/zod-json-schema-compat.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Allow additional JSON Schema properties in elicitInput's requestedSchema type by adding .catchall(z.unknown()), matching the pattern used by inputSchema. This fixes type incompatibility when using Zod v4's .toJSONSchema() output which includes extra properties like $schema and additionalProperties. diff --git a/.changeset/zod-jsonschema-fallback.md b/.changeset/zod-jsonschema-fallback.md new file mode 100644 index 0000000..e2936cf --- /dev/null +++ b/.changeset/zod-jsonschema-fallback.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/client': patch +--- + +Fix runtime crash on `tools/list` when a tool's `inputSchema` comes from zod 4.0–4.1. The SDK requires `~standard.jsonSchema` (StandardJSONSchemaV1, added in zod 4.2.0); previously a missing `jsonSchema` crashed at `undefined[io]`. `standardSchemaToJsonSchema` now detects zod 4 schemas lacking `jsonSchema` and falls back to the SDK-bundled `z.toJSONSchema()`, emitting a one-time console warning. zod 3 schemas (which the bundled zod 4 converter cannot introspect) and non-zod schema libraries without `jsonSchema` get a clear error pointing to `fromJsonSchema()`. The workspace zod catalog is also bumped to `^4.2.0`. diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e69de29 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..596e699 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,11 @@ +# TypeScript SDK Code Owners + +# Default owners for everything in the repo +* @modelcontextprotocol/typescript-sdk + +# Auth team owns all auth-related code +/src/server/auth/ @modelcontextprotocol/typescript-sdk-auth +/src/client/auth* @modelcontextprotocol/typescript-sdk-auth +/src/shared/auth* @modelcontextprotocol/typescript-sdk-auth +/src/examples/client/simpleOAuthClient.ts @modelcontextprotocol/typescript-sdk-auth +/src/examples/server/demoInMemoryOAuthProvider.ts @modelcontextprotocol/typescript-sdk-auth \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b18fd29 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..58d7921 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,41 @@ +# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && !startsWith(github.event.comment.body, '@claude review')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + use_commit_signing: true + additional_permissions: | + actions: read diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..2179f02 --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,51 @@ +name: Conformance Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: conformance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + client-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install + - run: pnpm run build:all + - run: pnpm run test:conformance:client:all + + server-conformance: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v6 + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install + - run: pnpm run build:all + - run: pnpm run test:conformance:server diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..02a5739 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,53 @@ +name: Deploy Docs + +on: + push: + branches: + - main + - v1.x + workflow_dispatch: + +concurrency: + group: deploy-docs + cancel-in-progress: true + +jobs: + deploy-docs: + runs-on: ubuntu-latest + + permissions: + contents: read + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Generate multi-version docs + run: bash scripts/generate-multidoc.sh tmp/docs-combined + + - name: Configure Pages + uses: actions/configure-pages@v6 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: tmp/docs-combined + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..4b49b30 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,94 @@ +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + - run: pnpm run check:all + - run: pnpm run build:all + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [20, 22, 24] + + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - run: pnpm install + + - run: pnpm test:all + + test-runtimes: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - runtime: bun + version: "1.x" + - runtime: deno + version: v2.x + steps: + - uses: actions/checkout@v6 + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Set up Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ matrix.version }} + - name: Set up Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2 + with: + deno-version: ${{ matrix.version }} + - run: pnpm install + - run: pnpm build:all + - name: Run ${{ matrix.runtime }} integration tests + run: pnpm --filter @modelcontextprotocol/test-integration test:integration:${{ matrix.runtime }} + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f78f146 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish Any Commit + +permissions: + contents: read + +on: + pull_request: + push: + branches: + - '**' + tags: + - '!**' + +jobs: + pkg-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm run build:all + + - name: Publish preview packages + run: + pnpm dlx pkg-pr-new publish --packageManager=npm --pnpm './packages/server' './packages/client' + './packages/codemod' './packages/middleware/express' './packages/middleware/fastify' './packages/middleware/hono' './packages/middleware/node' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bc033de --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,82 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + version: + name: Version + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + hasChangesets: ${{ steps.changesets.outputs.hasChangesets }} + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install + + - name: Create or update Version Packages PR + id: changesets + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1 + env: + GITHUB_TOKEN: ${{ github.token }} + + publish: + name: Publish + needs: version + if: needs.version.outputs.hasChangesets == 'false' + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install + + # pnpm@10 delegates `pnpm publish` to the npm CLI; OIDC trusted publishing + # requires npm >=11.5.1, which Node 24's bundled npm only satisfies from + # ~24.6 onward. Install a recent-enough npm so we don't depend on which Node patch resolves. + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@11.5.1 + + - name: Publish to npm + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1 + with: + publish: pnpm run ci:publish + env: + GITHUB_TOKEN: ${{ github.token }} + NPM_CONFIG_PROVENANCE: 'true' diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml new file mode 100644 index 0000000..6f7bde8 --- /dev/null +++ b/.github/workflows/update-spec-types.yml @@ -0,0 +1,84 @@ +name: Update Spec Types + +on: + schedule: + # Run nightly at 4 AM UTC + - cron: '0 4 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-spec-types: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install + + - name: Fetch latest spec types + run: pnpm run fetch:spec-types + + - name: Check for changes + id: check_changes + run: | + if git diff --quiet packages/core/src/types/spec.types.ts; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + LATEST_SHA=$(grep "Last updated from commit:" packages/core/src/types/spec.types.ts | cut -d: -f2 | tr -d ' ') + echo "sha=$LATEST_SHA" >> $GITHUB_OUTPUT + fi + + - name: Create Pull Request + if: steps.check_changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ github.token }} + # Skip lefthook pre-push (typecheck/lint/build); spec drift that breaks + # typecheck should still open a PR so it can be fixed there. + LEFTHOOK: 0 + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git checkout -B update-spec-types + git add packages/core/src/types/spec.types.ts + git commit -m "chore: update spec.types.ts from upstream" + git push -f --no-verify origin update-spec-types + + # Create PR if it doesn't exist, or update if it does + PR_BODY="This PR updates \`packages/core/src/types/spec.types.ts\` from the Model Context Protocol specification. + + Source file: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/${{ steps.check_changes.outputs.sha }}/schema/draft/schema.ts + + This is an automated update triggered by the nightly cron job." + + # `gh pr view ` matches closed PRs too, so check for an *open* PR explicitly. + EXISTING_PR=$(gh pr list --head update-spec-types --state open --json number --jq '.[0].number // empty') + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists, updating description..." + gh pr edit "$EXISTING_PR" --body "$PR_BODY" + else + gh pr create \ + --title "chore: update spec.types.ts from upstream" \ + --body "$PR_BODY" \ + --base main \ + --head update-spec-types + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6372eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Temporary files +tmp/ + +# Logs +logs +*.log +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +.DS_Store +dist/ + +# IDE +.idea/ +.cursor/ + +# Git worktrees for local doc generation +.worktrees/ + +# Conformance test results +results/ + +# Ignore local lefthook configuration +lefthook-local.yml diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..218528f --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry = "https://registry.npmjs.org/" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6877ccc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Ignore artifacts: +build +dist +coverage +*-lock.* +node_modules +**/build +**/dist +.github/CODEOWNERS +pnpm-lock.yaml + +# Ignore generated files +src/spec.types.ts + +# Batch test cloned repos and results +packages/codemod/batch-test/repos +packages/codemod/batch-test/results + +# Quickstart examples uses 2-space indent to match ecosystem conventions +examples/client-quickstart/ +examples/server-quickstart/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..840a2c6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,20 @@ +{ + "printWidth": 140, + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "bracketSameLine": false, + "proseWrap": "always", + "arrowParens": "avoid", + "overrides": [ + { + "files": "**/*.md", + "options": { + "printWidth": 280 + } + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cbbf950 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,280 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build & Test Commands + +```sh +pnpm install # Install all workspace dependencies + +pnpm build:all # Build all packages +pnpm lint:all # Run ESLint + Prettier checks across all packages +pnpm lint:fix:all # Auto-fix lint and formatting issues across all packages +pnpm typecheck:all # Type-check all packages +pnpm test:all # Run all tests (vitest) across all packages +pnpm check:all # typecheck + lint across all packages + +# Run a single package script (examples) +# Run a single package script from the repo root with pnpm filter +pnpm --filter @modelcontextprotocol/core test # vitest run (core) +pnpm --filter @modelcontextprotocol/core test:watch # vitest (watch) +pnpm --filter @modelcontextprotocol/core test -- path/to/file.test.ts +pnpm --filter @modelcontextprotocol/core test -- -t "test name" +``` + +## Breaking Changes + +When making breaking changes, document them in **both**: + +- `docs/migration.md` — human-readable guide with before/after code examples +- `docs/migration-SKILL.md` — LLM-optimized mapping tables for mechanical migration + +Include what changed, why, and how to migrate. Search for related sections and group related changes together rather than adding new standalone sections. + +## Code Style Guidelines + +- **TypeScript**: Strict type checking, ES modules, explicit return types +- **Naming**: PascalCase for classes/types, camelCase for functions/variables +- **Files**: Lowercase with hyphens, test files with `.test.ts` suffix +- **Imports**: ES module style, include `.js` extension, group imports logically +- **Formatting**: 2-space indentation, semicolons required, single quotes preferred +- **Testing**: Place tests under each package's `test/` directory (vitest only includes `test/**/*.test.ts`), use descriptive test names +- **Comments**: JSDoc for public APIs, inline comments for complex logic + +### JSDoc `@example` Code Snippets + +JSDoc `@example` tags should pull type-checked code from companion `.examples.ts` files (e.g., `client.ts` → `client.examples.ts`). Use `` ```ts source="./file.examples.ts#regionName" `` fences referencing `//#region regionName` blocks; region names follow `exportedName_variant` or `ClassName_methodName_variant` pattern (e.g., `applyMiddlewares_basicUsage`, `Client_connect_basicUsage`). For whole-file inclusion (any file type), omit the `#regionName`. + +Run `pnpm sync:snippets` to sync example content into JSDoc comments and markdown files. + +## Architecture Overview + +### Core Layers + +The SDK is organized into three main layers: + +1. **Types Layer** (`packages/core/src/types/types.ts`) - Protocol types generated from the MCP specification. All JSON-RPC message types, schemas, and protocol constants are defined here using Zod v4. + +2. **Protocol Layer** (`packages/core/src/shared/protocol.ts`) - The abstract `Protocol` class that handles JSON-RPC message routing, request/response correlation, capability negotiation, and transport management. Both `Client` and `Server` extend this class. + +3. **High-Level APIs**: + - `Client` (`packages/client/src/client/client.ts`) - Client implementation extending Protocol with typed methods for MCP operations + - `Server` (`packages/server/src/server/server.ts`) - Server implementation extending Protocol with request handler registration + - `McpServer` (`packages/server/src/server/mcp.ts`) - High-level server API with simplified resource/tool/prompt registration + +### Public API Exports + +The SDK has a two-layer export structure to separate internal code from the public API: + +- **`@modelcontextprotocol/core`** (main entry, `packages/core/src/index.ts`) — Internal barrel. Exports everything (including Zod schemas, Protocol class, stdio utils). Only consumed by sibling packages within the monorepo (`private: true`). +- **`@modelcontextprotocol/core/public`** (`packages/core/src/exports/public/index.ts`) — Curated public API. Exports only TypeScript types, error classes, constants, and guards. Re-exported by client and server packages. +- **`@modelcontextprotocol/client`** and **`@modelcontextprotocol/server`** (`packages/*/src/index.ts`) — Final public surface. Package-specific exports (named explicitly) plus re-exports from `core/public`. + +When modifying exports: +- Use explicit named exports, not `export *`, in package `index.ts` files and `core/public`. +- Adding a symbol to a package `index.ts` makes it public API — do so intentionally. +- Internal helpers should stay in the core internal barrel and not be added to `core/public` or package index files. +- The package root entry must stay runtime-neutral so browser and Cloudflare Workers bundlers can consume it. Exports whose module graph transitively touches unpolyfillable Node builtins (`node:child_process`, `node:net`, `cross-spawn`, etc.) must live at a named subpath export (e.g. `./stdio`) and be covered by a `barrelClean` test in that package. + +### Transport System + +Transports (`packages/core/src/shared/transport.ts`) provide the communication layer: + +- **Streamable HTTP** (`packages/server/src/server/streamableHttp.ts`, `packages/client/src/client/streamableHttp.ts`) - Recommended transport for remote servers, supports SSE for streaming +- **SSE** (`packages/server/src/server/sse.ts`, `packages/client/src/client/sse.ts`) - Legacy HTTP+SSE transport for backwards compatibility +- **stdio** (`packages/server/src/server/stdio.ts`, `packages/client/src/client/stdio.ts`) - For local process-spawned integrations + +### Server-Side Features + +- **Tools/Resources/Prompts**: Registered via `McpServer.tool()`, `.resource()`, `.prompt()` methods +- **OAuth/Auth**: Full OAuth 2.0 server implementation in `packages/server/src/server/auth/` +- **Completions**: Auto-completion support via `packages/server/src/server/completable.ts` + +### Client-Side Features + +- **Auth**: OAuth client support in `packages/client/src/client/auth.ts` and `packages/client/src/client/auth-extensions.ts` +- **Client middleware**: Request middleware in `packages/client/src/client/middleware.ts` (unrelated to the framework adapter packages below) +- **Sampling**: Clients can handle `sampling/createMessage` requests from servers (LLM completions) +- **Elicitation**: Clients can handle `elicitation/create` requests for user input (form or URL mode) +- **Roots**: Clients can expose filesystem roots to servers via `roots/list` + +### Middleware packages (framework/runtime adapters) + +The repo also ships “middleware” packages under `packages/middleware/` (e.g. `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/node`). These are thin integration layers for specific frameworks/runtimes and should not add new MCP functionality. + +### Experimental Features + +Located in `packages/*/src/experimental/`: + +- **Tasks**: Long-running task support with polling/resumption (`packages/core/src/experimental/tasks/`) + +### Zod Schemas + +The SDK uses `zod/v4` internally. Schema utilities live in: + +- `packages/core/src/util/schema.ts` - AnySchema alias and helpers for inspecting Zod objects + +### Validation + +Pluggable JSON Schema validation (`packages/core/src/validators/`): + +- `ajvProvider.ts` - Default Ajv-based validator +- `cfWorkerProvider.ts` - Cloudflare Workers-compatible alternative + +### Examples + +Runnable examples in `examples/`: + +- `examples/server/src/` - Various server configurations (stateful, stateless, OAuth, etc.) +- `examples/client/src/` - Client examples (basic, OAuth, parallel calls, etc.) +- `examples/shared/src/` - Shared utilities (OAuth demo provider, etc.) + +## Message Flow (Bidirectional Protocol) + +MCP is bidirectional: both client and server can send requests. Understanding this flow is essential when implementing new request types. + +### Class Hierarchy + +``` +Protocol (abstract base) +├── Client (packages/client/src/client/client.ts) - can send requests TO server, handle requests FROM server +└── Server (packages/server/src/server/server.ts) - can send requests TO client, handle requests FROM client + └── McpServer (packages/server/src/server/mcp.ts) - high-level wrapper around Server +``` + +### Outbound Flow: Sending Requests + +When code calls `client.callTool()` or `server.createMessage()`: + +1. **High-level method** (e.g., `Client.callTool()`) calls `this.request()` +2. **`Protocol.request()`**: + - Assigns unique message ID + - Checks capabilities via `assertCapabilityForMethod()` (abstract, implemented by Client/Server) + - Creates response handler promise + - Calls `transport.send()` with JSON-RPC request + - Waits for response handler to resolve +3. **Transport** serializes and sends over wire (HTTP, stdio, etc.) +4. **`Protocol._onresponse()`** resolves the promise when response arrives + +### Inbound Flow: Handling Requests + +When a request arrives from the remote side: + +1. **Transport** receives message, calls `transport.onmessage()` +2. **`Protocol.connect()`** routes to `_onrequest()`, `_onresponse()`, or `_onnotification()` +3. **`Protocol._onrequest()`**: + - Looks up handler in `_requestHandlers` map (keyed by method name) + - Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc. + - Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info) + - Invokes handler, sends JSON-RPC response back via transport +4. **Handler** was registered via `setRequestHandler('method', handler)` + +### Handler Registration + +```typescript +// In Client (for server→client requests like sampling, elicitation) +client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + // Handle sampling request from server + return { role: "assistant", content: {...}, model: "..." }; +}); + +// In Server (for client→server requests like tools/call) +server.setRequestHandler('tools/call', async (request, ctx) => { + // Handle tool call from client + return { content: [...] }; +}); +``` + +### Request Handler Context + +The `ctx` parameter in handlers provides a structured context: + +**`BaseContext`** (common to both Server and Client), fields organized into nested groups: + +- `sessionId?`: Transport session identifier +- `mcpReq`: Request-level concerns + - `id`: JSON-RPC message ID + - `method`: Request method string (e.g., 'tools/call') + - `_meta?`: Request metadata + - `signal`: AbortSignal for cancellation + - `send(request, schema, options?)`: Send related request (for bidirectional flows) + - `notify(notification)`: Send related notification back +- `http?`: HTTP transport info (undefined for stdio) + - `authInfo?`: Validated auth token info +- `task?`: Task context (`{ id?, store, requestedTtl? }`) when task storage is configured + +**`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection: + +- `mcpReq` adds: `log(level, data, logger?)`, `elicitInput(params, options?)`, `requestSampling(params, options?)` +- `http?` adds: `req?` (HTTP request info), `closeSSE?`, `closeStandaloneSSE?` + +**`ClientContext`** is currently identical to `BaseContext`. + +### Capability Checking + +Both sides declare capabilities during initialization. The SDK enforces these: + +- **Client→Server**: `Client.assertCapabilityForMethod()` checks `_serverCapabilities` +- **Server→Client**: `Server.assertCapabilityForMethod()` checks `_clientCapabilities` +- **Handler registration**: `assertRequestHandlerCapability()` validates local capabilities + +### Adding a New Request Type + +1. **Define schema** in `src/types.ts` (request params, result schema) +2. **Add capability** to `ClientCapabilities` or `ServerCapabilities` in types +3. **Implement sender** method in Client or Server class +4. **Add capability check** in the appropriate `assertCapabilityForMethod()` +5. **Register handler** on the receiving side with `setRequestHandler()` +6. **For McpServer**: Add high-level wrapper method if needed + +### Server-Initiated Requests (Sampling, Elicitation) + +Server can request actions from client (requires client capability): + +```typescript +// Server sends sampling request to client +const result = await server.createMessage({ + messages: [...], + maxTokens: 100 +}); + +// Client must have registered handler: +client.setRequestHandler('sampling/createMessage', async (request, extra) => { + // Client-side LLM call + return { role: "assistant", content: {...} }; +}); +``` + +## Key Patterns + +### Request Handler Registration (Low-Level Server) + +```typescript +server.setRequestHandler('tools/call', async (request, extra) => { + // extra contains sessionId, authInfo, sendNotification, etc. + return { + /* result */ + }; +}); +``` + +### Tool Registration (High-Level McpServer) + +```typescript +mcpServer.tool('tool-name', { param: z.string() }, async ({ param }, extra) => { + return { content: [{ type: 'text', text: 'result' }] }; +}); +``` + +### Transport Connection + +```typescript +// Server +// (Node.js IncomingMessage/ServerResponse wrapper; exported by @modelcontextprotocol/node) +const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); +await server.connect(transport); + +// Client +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); +await client.connect(transport); +``` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..62c701a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, +or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as +well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is +allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at . + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at . Translations are available at . diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..325330c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,187 @@ +# Contributing to MCP TypeScript SDK + +Welcome, and thanks for your interest in contributing! We're glad you're here. + +This document outlines how to contribute effectively to the TypeScript SDK. + +## Issues + +### Discuss Before You Code + +**Please open an issue before starting work on new features or significant changes.** This gives us a chance to align on approach and save you time if we see potential issues. + +We'll close PRs for undiscussed features—not because we don't appreciate the effort, but because every merged feature becomes an ongoing maintenance burden for our small team of maintainers. Talking first helps us figure out together whether something belongs in the SDK. + +Straightforward bug fixes (a few lines of code with tests demonstrating the fix) can skip this step. For complex bugs that need significant changes, consider opening an issue first. + +### What Counts as "Significant"? + +- New public APIs or classes +- Architectural changes or refactoring +- Changes that touch multiple modules +- Features that might require spec changes (these need a [SEP](https://modelcontextprotocol.io/community/sep-guidelines) first) + +### Writing Good Issues + +Help us help you: + +- Lead with what's broken or what you need +- Include code we can run to see the problem +- Keep it focused—a clear problem statement goes a long way + +We're a small team, so issues that include some upfront debugging help us move faster. Low-effort or obviously AI-generated issues will be closed. + +### Finding Issues to Work On + +| Label | For | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------- | +| [`good first issue`](https://github.com/modelcontextprotocol/typescript-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Newcomers | Can tackle without deep codebase knowledge | +| [`help wanted`](https://github.com/modelcontextprotocol/typescript-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this | +| [`ready for work`](https://github.com/modelcontextprotocol/typescript-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up | + +Issues labeled `needs confirmation`, `needs repro`, or `needs design` are **not** ready for work—wait for maintainer input before starting. + +Before starting work, comment on the issue so we can assign it to you. This lets others know and avoids duplicate effort. + +## Pull Requests + +By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps PR reviews focused on implementation rather than revisiting whether we should do it at all. + +### Branches + +This repository has two main branches: + +- **`main`** – v2 of the SDK (currently in development). This is a monorepo with split packages. +- **`v1.x`** – stable v1 release. Bug fixes and patches for v1 should target this branch. + +**Which branch should I use as a base?** + +- For **new features** or **v2-related work**: base your PR on `main` +- For **v1 bug fixes** or **patches**: base your PR on `v1.x` + +### Scope + +Small PRs get reviewed fast. Large PRs sit in the queue. + +We can review a few dozen lines in a few minutes. But a PR touching hundreds of lines across many files takes real effort to verify—and things inevitably slip through. If your change is big, break it into a stack of smaller PRs or get clear alignment from a maintainer on your +approach in an issue before submitting a large PR. + +### What Gets Rejected + +PRs may be rejected for: + +- **Lack of prior discussion** — Features or significant changes without an approved issue +- **Scope creep** — Changes that go beyond what was discussed or add unrequested features +- **Misalignment with SDK direction** — Even well-implemented features may be rejected if they don't fit the SDK's goals +- **Insufficient quality** — Code that doesn't meet clarity, maintainability, or style standards +- **Overengineering** — Unnecessary complexity or abstraction for simple problems + +### Submitting Your PR + +1. Follow the existing code style +2. Include tests for new functionality +3. Update documentation as needed +4. Keep changes focused and atomic +5. Provide a clear description of changes + +## Development + +### Getting Started + +This project uses [pnpm](https://pnpm.io/) as its package manager. If you don't have pnpm installed, enable it via [corepack](https://nodejs.org/api/corepack.html) (included with Node.js 16.9+): + +```bash +corepack enable +``` + +Then: + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/typescript-sdk.git` +3. Install dependencies: `pnpm install` +4. Build the project: `pnpm build:all` +5. Run tests: `pnpm test:all` + +### Workflow + +1. Create a new branch for your changes (based on `main` or `v1.x` as appropriate) +2. Make your changes +3. Run `pnpm lint:all` to ensure code style compliance +4. Run `pnpm test:all` to verify all tests pass +5. Submit a pull request + +### Running Examples + +See [`examples/server/README.md`](examples/server/README.md) and [`examples/client/README.md`](examples/client/README.md) for a full list of runnable examples. + +Quick start: + +```bash +# Run a server example +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts + +# Run a client example (in another terminal) +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts +``` + +## Releasing v1.x Patches + +The `v1.x` branch contains the stable v1 release. To release a patch: + +### Latest v1.x (e.g., v1.25.3) + +```bash +git checkout v1.x +git pull origin v1.x +# Apply your fix or cherry-pick commits +npm version patch # Bumps version and creates tag (e.g., v1.25.3) +git push origin v1.x --tags +``` + +The tag push automatically triggers the release workflow. + +### Older minor versions (e.g., v1.23.2) + +For patching older minor versions that aren't on the `v1.x` branch: + +```bash +# 1. Create a release branch from the last release tag +git checkout -b release/1.23 v1.23.1 + +# 2. Apply your fixes (cherry-pick or manual) +git cherry-pick + +# 3. Bump version and push +npm version patch # Creates v1.23.2 tag +git push origin release/1.23 --tags +``` + +Then manually trigger the "Publish v1.x" workflow from [GitHub Actions](https://github.com/modelcontextprotocol/typescript-sdk/actions/workflows/release-v1x.yml), specifying the tag (e.g., `v1.23.2`). + +### npm Tags + +v1.x releases are published with `release-X.Y` npm tags (e.g., `release-1.25`), not `latest`. To install a specific minor version: + +```bash +npm install @modelcontextprotocol/sdk@release-1.25 +``` + +## Policies + +### Code of Conduct + +This project follows our [Code of Conduct](CODE_OF_CONDUCT.md). Please review it before contributing. + +### Reporting Issues + +- Use the [GitHub issue tracker](https://github.com/modelcontextprotocol/typescript-sdk/issues) +- Search existing issues before creating a new one +- Provide clear reproduction steps + +### Security Issues + +Please review our [Security Policy](SECURITY.md) for reporting security vulnerabilities. + +### License + +By contributing, you agree that your code contributions will be licensed under the Apache License 2.0. Documentation contributions (excluding specifications) are licensed under CC-BY 4.0. See the [LICENSE](LICENSE) file for details. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a93985 --- /dev/null +++ b/LICENSE @@ -0,0 +1,216 @@ +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/README.md b/README.md new file mode 100644 index 0000000..e81334b --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# MCP TypeScript SDK + + +> [!IMPORTANT] +> **This is the `main` branch which contains v2 of the SDK (currently in development, pre-alpha).** +> +> We anticipate a stable v2 release in Q1 2026. Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. +> +> For v1 documentation, see the [V1 API docs](https://ts.sdk.modelcontextprotocol.io/). For v2 API docs, see [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). + +[![NPM Version - Server](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fserver?label=%40modelcontextprotocol%2Fserver)](https://www.npmjs.com/package/@modelcontextprotocol/server) +[![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver) + +
+Table of Contents + +- [Overview](#overview) +- [Packages](#packages) +- [Installation](#installation) +- [Getting Started](#getting-started) +- [Documentation](#documentation) +- [Contributing](#contributing) +- [License](#license) + +
+ +## Overview + +The Model Context Protocol (MCP) allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. + +This repository contains the TypeScript SDK implementation of the MCP specification. It runs on **Node.js**, **Bun**, and **Deno**, and ships: + +- MCP **server** libraries (tools/resources/prompts, Streamable HTTP, stdio, auth helpers) +- MCP **client** libraries (transports, high-level helpers, OAuth helpers) +- Optional **middleware packages** for specific runtimes/frameworks (Express, Hono, Node.js HTTP) +- Runnable **examples** (under [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples)) + +## Packages + +This monorepo publishes split packages: + +- **`@modelcontextprotocol/server`**: build MCP servers +- **`@modelcontextprotocol/client`**: build MCP clients + +Tool and prompt schemas use [Standard Schema](https://standardschema.dev/) — bring Zod v4, Valibot, ArkType, or any compatible library. + +### Middleware packages (optional) + +The SDK also publishes small "middleware" packages under [`packages/middleware/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/middleware) that help you **wire MCP into a specific runtime or web framework**. + +They are intentionally thin adapters: they should not introduce new MCP functionality or business logic. See [`packages/middleware/README.md`](packages/middleware/README.md) for details. + +- **`@modelcontextprotocol/node`**: Node.js Streamable HTTP transport wrapper for `IncomingMessage` / `ServerResponse` +- **`@modelcontextprotocol/express`**: Express helpers (app defaults + Host header validation) +- **`@modelcontextprotocol/hono`**: Hono helpers (app defaults + JSON body parsing hook + Host header validation) + +## Installation + +### Server + +```bash +npm install @modelcontextprotocol/server +# or +bun add @modelcontextprotocol/server +# or +deno add npm:@modelcontextprotocol/server +``` + +### Client + +```bash +npm install @modelcontextprotocol/client +# or +bun add @modelcontextprotocol/client +# or +deno add npm:@modelcontextprotocol/client +``` + +### Optional middleware packages + +The SDK also publishes optional “middleware” packages that help you **wire MCP into a specific runtime or web framework** (for example Express, Hono, or Node.js `http`). + +These packages are intentionally thin adapters and should not introduce additional MCP features or business logic. See [`packages/middleware/README.md`](packages/middleware/README.md) for details. + +```bash +# Node.js HTTP (IncomingMessage/ServerResponse) Streamable HTTP transport: +npm install @modelcontextprotocol/node + +# Express integration: +npm install @modelcontextprotocol/express express + +# Hono integration: +npm install @modelcontextprotocol/hono hono +``` + +## Getting Started + +Here is what an MCP server looks like. This minimal example exposes a single `greet` tool over stdio: + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'greeting-server', version: '1.0.0' }); + +server.registerTool( + 'greet', + { + description: 'Greet someone by name', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main(); +``` + +Ready to build something real? Follow the step-by-step quickstart tutorials: + +- [Build a weather server](docs/server-quickstart.md) — server quickstart +- [Build an LLM-powered chatbot](docs/client-quickstart.md) — client quickstart + +The complete code for each tutorial is in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/) and +[`examples/client-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart/). For more advanced runnable examples, see: + +- [`examples/server/README.md`](examples/server/README.md) — server examples index +- [`examples/client/README.md`](examples/client/README.md) — client examples index + +## Documentation + +- [Server Guide](docs/server.md) — building MCP servers: transports, tools, resources, prompts, server-initiated requests, and deployment +- [Client Guide](docs/client.md) — building MCP clients: connecting, tools, resources, prompts, server-initiated requests, and error handling +- [FAQ](docs/faq.md) — frequently asked questions and troubleshooting +- [API docs](https://modelcontextprotocol.github.io/typescript-sdk/) +- [MCP documentation](https://modelcontextprotocol.io/docs) +- [MCP specification](https://modelcontextprotocol.io/specification/latest) + +### Building docs locally + +To generate the API reference documentation locally: + +```bash +pnpm docs # Generate V2 docs only (output: tmp/docs/) +pnpm docs:multi # Generate combined V1 + V2 docs (output: tmp/docs-combined/) +``` + +The `docs:multi` script checks out both the `v1.x` and `main` branches via git worktrees, builds each, and produces a combined site with V1 docs at the root and V2 docs under `/v2/`. + +## v1 (legacy) documentation and fixes + +If you are using the **v1** generation of the SDK, the **v1 API documentation** is available at [`https://ts.sdk.modelcontextprotocol.io/`](https://ts.sdk.modelcontextprotocol.io/). The v1 source code and any v1-specific fixes live on the long-lived +[`v1.x` branch](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x). V2 API docs are at [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). + +## Contributing + +Issues and pull requests are welcome on GitHub at . + +## License + +This project is licensed under the Apache License 2.0 for new contributions, with existing code under MIT. See the [LICENSE](LICENSE) file for details. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..ad726c7 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,95 @@ +# typescript-sdk Review Conventions + +Guidance for reviewing pull requests on this repository. The first three sections are +stable principles; the **Recurring Catches** section is auto-maintained from past human +review rounds and grows over time. + +## Guiding Principles + +1. **Minimalism** — The SDK should do less, not more. Protocol correctness, transport + lifecycle, types, and clean handler context belong in the SDK. Middleware engines, + registry managers, builder patterns, and content helpers belong in userland. +2. **Burden of proof is on addition** — The default answer to "should we add this?" is + no. Removing something from the public API is far harder than not adding it. +3. **Justify with concrete evidence** — Every new abstraction needs a concrete consumer + today. Ask for real issues, benchmarks, real-world examples; apply the same standard + to your own review (link spec sections, link code, show the simpler alternative). +4. **Spec is the anchor** — The SDK implements the protocol spec. The further a feature + drifts from the spec, the stronger the justification needs to be. +5. **Kill at the highest level** — If the design is wrong, don't review the + implementation. Lead with the highest-level concern; specific bugs are supporting + detail. +6. **Decompose by default** — A PR doing multiple things should be multiple PRs unless + there's a strong reason to bundle. + +## Review Ordering + +1. **Design justification** — Is the overall approach sound? Is the complexity warranted? +2. **Structural concerns** — Is the architecture right? Are abstractions justified? +3. **Correctness** — Bugs, regressions, missing functionality. +4. **Style and naming** — Nits, conventions, documentation. + +## Checklist + +**Protocol & spec** +- Types match [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) exactly (optional vs required fields) +- Correct `ProtocolError` codes (enum `ProtocolErrorCode`); HTTP status codes match spec (e.g., 404 vs 410) +- Works for both stdio and Streamable HTTP transports — no transport-specific assumptions +- Cross-SDK consistency: check what `python-sdk` does for the same feature + +**API surface** +- Every new export is intentional (see CLAUDE.md § Public API Exports); helpers users can write themselves belong in a cookbook, not the SDK +- New abstractions have at least one concrete callsite in the PR +- One way to do things — improving an existing API beats adding a parallel one + +**Correctness** +- Async: race conditions, cleanup on cancellation, unhandled rejections, missing `await` +- Error propagation: caught/rethrown properly, resources cleaned up on error paths +- Type safety: no unjustified `any`, no unsafe `as` assertions +- Backwards compat: public-interface changes, default changes, removed exports — flagged and justified + +**Tests & docs** +- New behavior has vitest coverage including error paths +- Breaking changes documented in `docs/migration.md` and `docs/migration-SKILL.md` +- Bugfix or behavior change: check whether `docs/**/*.md` describes the old behavior and needs updating; flag prose that now contradicts the implementation +- New feature: verify prose documentation is added (not just JSDoc), and assess whether `examples/` needs a new or updated example +- Behavior change: assess whether existing `examples/` still compile and demonstrate the current API + +## Reference + +When verifying spec compliance, consult the spec directly rather than relying on memory: + +- MCP documentation server: `https://modelcontextprotocol.io/mcp` +- Full spec text (single file, LLM-friendly): `https://modelcontextprotocol.io/llms-full.txt` — fetch to a temp file and grep for the relevant section +- Schema source of truth: [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) + +## Recurring Catches + +### HTTP Transport + +- When validating `Mcp-Session-Id`, return **400** for a missing header and **404** for an unknown/expired session — never conflate `!sessionId || !transports[sessionId]` into one status, because the client needs to distinguish "fix your request" from "start a new session". Flag any diff that branches on session-id presence/lookup with a single 4xx. (#1707, #1770) + +### Error Handling + +- Broad `catch` blocks must not emit client-fault JSON-RPC codes (`-32700` ParseError, `-32602` InvalidParams) for server-internal failures like stream setup, task-store misses, or polling errors — map those to `-32603` InternalError so clients don't retry/reformat pointlessly. Flag any catch-all that hard-codes ParseError/InvalidParams without discriminating the thrown cause. (#1752, #1769) + +### Schema Compliance + +- When editing Zod protocol schemas in `schemas.ts`, verify unknown-key handling matches the spec `schema.ts`: if the spec type has no `additionalProperties: false`, the SDK schema must use `z.looseObject()` / `.catchall(z.unknown())` rather than implicit strict — over-strict Zod (incl. `z.literal('object')` on `type`) rejects spec-valid payloads from other SDKs. Also confirm `spec.types.test.ts` still passes bidirectionally. (#1768, #1849, #1169) + +### Async / Lifecycle + +- In `close()` / shutdown paths, wrap user-supplied or chained callbacks (`onclose?.()`, cancel fns) in `try/finally` so a throw can't skip the remaining teardown (`abort()`, `_onclose()`, map clears) — otherwise the transport is left half-open. (#1735, #1763) +- Deferred callbacks (`setTimeout`, `.finally()`, reconnect closures) must check closed/aborted state before mutating `this._*` or starting I/O — a callback scheduled pre-close can fire after close/reconnect and corrupt the new connection's state (e.g., delete the new request's `AbortController`). (#1735, #1763) + +### Completeness + +- When a PR replaces a pattern (error class, auth-flow step, catch shape), grep the package for surviving instances of the old form — partial migrations leave sibling code paths with the very bug the PR claims to fix. Flag every leftover site. (#1657, #1761, #1595) + +### Documentation & Changesets + +- Read added `.changeset/*.md` text and new inline comments against the implementation in the same diff — prose that promises behavior the code no longer ships misleads consumers and contradicts stated intent. Flag any claim the diff doesn't back. (#1718, #1838) + +### CI & GitHub Actions + +- Do **not** assert that a third-party GitHub Action or publish toolchain will fail or needs extra permissions/tokens without verifying its docs or source — `pnpm publish` delegates to the system npm CLI (so npm OIDC works), and `changesets/action` in publish mode has no PR-comment step requiring `pull-requests: write`. For diffs under `.github/workflows/`, confirm claimed behavior in the action's README/source before flagging. (#1838, #1836) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5029242 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# 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/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs new file mode 100644 index 0000000..32aad92 --- /dev/null +++ b/common/eslint-config/eslint.config.mjs @@ -0,0 +1,109 @@ +// @ts-check +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import importPlugin from 'eslint-plugin-import'; +import nodePlugin from 'eslint-plugin-n'; +import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; +import eslintPluginUnicorn from 'eslint-plugin-unicorn'; +import { configs } from 'typescript-eslint'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig( + eslint.configs.recommended, + ...configs.recommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.typescript, + eslintPluginUnicorn.configs.recommended, + { + languageOptions: { + parserOptions: { + // Ensure consumers of this shared config get a stable tsconfig root + tsconfigRootDir: __dirname + } + }, + linterOptions: { + reportUnusedDisableDirectives: false + }, + plugins: { + n: nodePlugin, + 'simple-import-sort': simpleImportSortPlugin + }, + settings: { + 'import/resolver': { + typescript: { + // Let the TS resolver handle NodeNext-style imports like "./foo.js" + extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], + // Use the tsconfig in each package root (when running ESLint from that package) + project: 'tsconfig.json' + } + } + }, + rules: { + 'unicorn/prevent-abbreviations': 'off', + 'unicorn/no-null': 'off', + 'unicorn/prefer-add-event-listener': 'off', + 'unicorn/no-useless-undefined': ['error', { checkArguments: false }], + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'n/prefer-node-protocol': 'error', + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], + 'simple-import-sort/imports': 'warn', + 'simple-import-sort/exports': 'warn', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: [ + '**/test/**', + '**/*.test.ts', + '**/*.test.tsx', + '**/scripts/**', + '**/vitest.config.*', + '**/tsdown.config.*', + '**/eslint.config.*', + '**/vitest.setup.*' + ], + optionalDependencies: false, + peerDependencies: true + } + ], + 'unicorn/filename-case': [ + 'error', + { + case: 'camelCase' + } + ] + } + }, + { + // Disable consistent-function-scoping in test files where helper functions are common + files: ['**/*.test.ts', '**/*.test.tsx', '**/test/**'], + rules: { + 'unicorn/consistent-function-scoping': 'off' + } + }, + { + // Example files contain intentionally unused functions (one per region) + files: ['**/*.examples.ts'], + rules: { + '@typescript-eslint/no-unused-vars': 'off', + 'no-console': 'off' + } + }, + { + // Ignore generated protocol types everywhere + ignores: ['**/spec.types.ts'] + }, + { + files: ['packages/client/**/*.ts', 'packages/server/**/*.ts'], + ignores: ['**/*.test.ts'], + rules: { + 'no-console': 'error' + } + }, + eslintConfigPrettier +); diff --git a/common/eslint-config/package.json b/common/eslint-config/package.json new file mode 100644 index 0000000..46294a5 --- /dev/null +++ b/common/eslint-config/package.json @@ -0,0 +1,37 @@ +{ + "name": "@modelcontextprotocol/eslint-config", + "private": true, + "main": "eslint.config.mjs", + "type": "module", + "exports": { + ".": "./eslint.config.mjs" + }, + "dependencies": { + "typescript": "catalog:devTools" + }, + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "bugs": { + "url": "https://github.com/modelcontextprotocol/typescript-sdk/issues" + }, + "homepage": "https://github.com/modelcontextprotocol/typescript-sdk/tree/develop/common/eslint-config", + "publishConfig": { + "registry": "https://npm.pkg.github.com/" + }, + "version": "2.0.0", + "devDependencies": { + "@eslint/js": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-n": "catalog:devTools", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-unicorn": "^62.0.0", + "prettier": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools" + } +} diff --git a/common/tsconfig/package.json b/common/tsconfig/package.json new file mode 100644 index 0000000..7c62db2 --- /dev/null +++ b/common/tsconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modelcontextprotocol/tsconfig", + "private": true, + "main": "tsconfig.json", + "type": "module", + "dependencies": { + "typescript": "catalog:devTools" + }, + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "bugs": { + "url": "https://github.com/modelcontextprotocol/typescript-sdk/issues" + }, + "homepage": "https://github.com/modelcontextprotocol/typescript-sdk/tree/develop/common/ts-config", + "publishConfig": { + "registry": "https://npm.pkg.github.com/" + }, + "version": "2.0.0" +} diff --git a/common/tsconfig/tsconfig.json b/common/tsconfig/tsconfig.json new file mode 100644 index 0000000..6db7d70 --- /dev/null +++ b/common/tsconfig/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "libReplacement": false, + "noImplicitReturns": true, + "incremental": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "paths": { + "pkce-challenge": ["./node_modules/pkce-challenge/dist/index.node"] + }, + "types": ["node", "vitest/globals"] + } +} diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json new file mode 100644 index 0000000..3ae5993 --- /dev/null +++ b/common/vitest-config/package.json @@ -0,0 +1,28 @@ +{ + "name": "@modelcontextprotocol/vitest-config", + "private": true, + "main": "vitest.config.mjs", + "type": "module", + "exports": { + ".": "./vitest.config.js" + }, + "dependencies": { + "typescript": "catalog:devTools" + }, + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "bugs": { + "url": "https://github.com/modelcontextprotocol/typescript-sdk/issues" + }, + "homepage": "https://github.com/modelcontextprotocol/typescript-sdk/tree/develop/common/vitest-config", + "publishConfig": { + "registry": "https://npm.pkg.github.com/" + }, + "version": "2.0.0", + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "vite-tsconfig-paths": "catalog:devTools" + } +} diff --git a/common/vitest-config/tsconfig.json b/common/vitest-config/tsconfig.json new file mode 100644 index 0000000..6e58368 --- /dev/null +++ b/common/vitest-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "include": ["./"], + "extends": "@modelcontextprotocol/tsconfig", + "compilerOptions": { + "noEmit": true, + "allowJs": true + } +} diff --git a/common/vitest-config/vitest.config.js b/common/vitest-config/vitest.config.js new file mode 100644 index 0000000..a2209e9 --- /dev/null +++ b/common/vitest-config/vitest.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import path from 'node:path'; +import url from 'node:url'; + +const ignorePatterns = ['**/dist/**']; +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + exclude: ignorePatterns, + deps: { + moduleDirectories: ['node_modules', path.resolve(__dirname, '../../packages'), path.resolve(__dirname, '../../common')] + } + }, + poolOptions: { + threads: { + useAtomics: true + } + }, + plugins: [tsconfigPaths()] +}); diff --git a/docs/client-quickstart.md b/docs/client-quickstart.md new file mode 100644 index 0000000..71b8a9e --- /dev/null +++ b/docs/client-quickstart.md @@ -0,0 +1,424 @@ +--- +title: Client Quickstart +--- + +# Quickstart: Build an LLM-powered chatbot + +In this tutorial, we'll build an LLM-powered chatbot that connects to an MCP server, discovers its tools, and uses Claude to call them. + +Before you begin, it helps to have gone through the [server quickstart](./server-quickstart.md) so you understand how clients and servers communicate. + +[You can find the complete code for this tutorial here.](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client-quickstart) + +## Prerequisites + +This quickstart assumes you have familiarity with: + +- TypeScript +- LLMs like Claude + +Before starting, ensure your system meets these requirements: + +- Node.js 20 or higher installed (or **Bun** / **Deno** — the SDK supports all three runtimes) +- Latest version of `npm` installed +- An Anthropic API key from the [Anthropic Console](https://console.anthropic.com/settings/keys) + +> [!TIP] +> This tutorial uses Node.js and npm, but you can substitute `bun` or `deno` commands where appropriate. For example, use `bun add` instead of `npm install`, or run the client with `bun run` / `deno run`. + +## Set up your environment + +First, let's create and set up our project: + +**macOS/Linux:** + +```bash +# Create project directory +mkdir mcp-client +cd mcp-client + +# Initialize npm project +npm init -y + +# Install dependencies +npm install @anthropic-ai/sdk @modelcontextprotocol/client + +# Install dev dependencies +npm install -D @types/node typescript + +# Create source file +mkdir src +touch src/index.ts +``` + +**Windows:** + +```powershell +# Create project directory +md mcp-client +cd mcp-client + +# Initialize npm project +npm init -y + +# Install dependencies +npm install @anthropic-ai/sdk @modelcontextprotocol/client + +# Install dev dependencies +npm install -D @types/node typescript + +# Create source file +md src +new-item src\index.ts +``` + +Update your `package.json` to set `type: "module"` and a build script: + +```json +{ + "type": "module", + "scripts": { + "build": "tsc" + } +} +``` + +Create a `tsconfig.json` in the root of your project: + +```json +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} +``` + +## Creating the client + +### Basic client structure + +First, let's set up our imports and create the basic client class in `src/index.ts`: + +```ts source="../examples/client-quickstart/src/index.ts#prelude" +import Anthropic from '@anthropic-ai/sdk'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import readline from 'readline/promises'; + +const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; + +class MCPClient { + private mcp: Client; + private _anthropic: Anthropic | null = null; + private transport: StdioClientTransport | null = null; + private tools: Anthropic.Tool[] = []; + + constructor() { + // Initialize MCP client + this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' }); + } + + private get anthropic(): Anthropic { + // Lazy-initialize Anthropic client when needed + return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + } +``` + +### Server connection management + +Next, we'll implement the method to connect to an MCP server: + +```ts source="../examples/client-quickstart/src/index.ts#connectToServer" + async connectToServer(serverScriptPath: string) { + try { + // Determine script type and appropriate command + const isJs = serverScriptPath.endsWith('.js'); + const isPy = serverScriptPath.endsWith('.py'); + if (!isJs && !isPy) { + throw new Error('Server script must be a .js or .py file'); + } + const command = isPy + ? (process.platform === 'win32' ? 'python' : 'python3') + : process.execPath; + + // Initialize transport and connect to server + this.transport = new StdioClientTransport({ command, args: [serverScriptPath] }); + await this.mcp.connect(this.transport); + + // List available tools + const toolsResult = await this.mcp.listTools(); + this.tools = toolsResult.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? '', + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + })); + console.log('Connected to server with tools:', this.tools.map(({ name }) => name)); + } catch (e) { + console.log('Failed to connect to MCP server: ', e); + throw e; + } + } +``` + +### Query processing logic + +Now let's add the core functionality for processing queries and handling tool calls: + +```ts source="../examples/client-quickstart/src/index.ts#processQuery" + async processQuery(query: string) { + const messages: Anthropic.MessageParam[] = [ + { + role: 'user', + content: query, + }, + ]; + + // Initial Claude API call + const response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); + + // Process response and handle tool calls + const finalText = []; + + for (const content of response.content) { + if (content.type === 'text') { + finalText.push(content.text); + } else if (content.type === 'tool_use') { + // Execute tool call + const toolName = content.name; + const toolArgs = content.input as Record | undefined; + const result = await this.mcp.callTool({ + name: toolName, + arguments: toolArgs, + }); + + finalText.push(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`); + + // Extract text from tool result content blocks + const toolResultText = result.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n'); + + // Continue conversation with tool results + messages.push({ + role: 'assistant', + content: response.content, + }); + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: content.id, + content: toolResultText, + }], + }); + + // Get next response from Claude + const followUp = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + }); + + finalText.push(followUp.content[0].type === 'text' ? followUp.content[0].text : ''); + } + } + + return finalText.join('\n'); + } +``` + +### Interactive chat interface + +Now we'll add the chat loop and cleanup functionality: + +```ts source="../examples/client-quickstart/src/index.ts#chatLoop" + async chatLoop() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log('\nMCP Client Started!'); + console.log('Type your queries or "quit" to exit.'); + + while (true) { + const message = await rl.question('\nQuery: '); + if (message.toLowerCase() === 'quit') { + break; + } + const response = await this.processQuery(message); + console.log('\n' + response); + } + } finally { + rl.close(); + } + } + + async cleanup() { + await this.mcp.close(); + } +} +``` + +### Main entry point + +Finally, we'll add the main execution logic: + +```ts source="../examples/client-quickstart/src/index.ts#main" +async function main() { + if (process.argv.length < 3) { + console.log('Usage: node build/index.js '); + return; + } + const mcpClient = new MCPClient(); + try { + await mcpClient.connectToServer(process.argv[2]); + + // Check if we have a valid API key to continue + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + console.log( + '\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:' + + '\n export ANTHROPIC_API_KEY=your-api-key-here' + ); + return; + } + + await mcpClient.chatLoop(); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await mcpClient.cleanup(); + process.exit(0); + } +} + +main(); +``` + +## Running the client + +To run your client with any MCP server: + +**macOS/Linux:** + +```bash +# Build TypeScript +npm run build + +# Run the client with a Node.js MCP server +ANTHROPIC_API_KEY=your-key-here node build/index.js path/to/server/build/index.js + +# Example: connect to the weather server from the server quickstart +ANTHROPIC_API_KEY=your-key-here node build/index.js /absolute/path/to/weather/build/index.js +``` + +**Windows:** + +```powershell +# Build TypeScript +npm run build + +# Run the client with a Node.js MCP server +$env:ANTHROPIC_API_KEY="your-key-here"; node build/index.js path\to\server\build\index.js +``` + +**The client will:** + +1. Connect to the specified server +2. List available tools +3. Start an interactive chat session where you can: + - Enter queries + - See tool executions + - Get responses from Claude + +## What's happening under the hood + +When you submit a query: + +1. Your query is sent to Claude along with the tool descriptions discovered during connection +2. Claude decides which tools (if any) to use +3. The client executes any requested tool calls through the server +4. Results are sent back to Claude +5. Claude provides a natural language response +6. The response is displayed to you + +## Troubleshooting + +### Server Path Issues + +- Double-check the path to your server script is correct +- Use the absolute path if the relative path isn't working +- For Windows users, make sure to use forward slashes (`/`) or escaped backslashes (`\\`) in the path +- Verify the server file has the correct extension (`.js` for Node.js or `.py` for Python) + +Example of correct path usage: + +**macOS/Linux:** + +```bash +# Relative path +node build/index.js ./server/build/index.js + +# Absolute path +node build/index.js /Users/username/projects/mcp-server/build/index.js +``` + +**Windows:** + +```powershell +# Relative path +node build/index.js .\server\build\index.js + +# Absolute path (either format works) +node build/index.js C:\projects\mcp-server\build\index.js +node build/index.js C:/projects/mcp-server/build/index.js +``` + +### Response Timing + +- The first response might take up to 30 seconds to return +- This is normal and happens while: + - The server initializes + - Claude processes the query + - Tools are being executed +- Subsequent responses are typically faster +- Don't interrupt the process during this initial waiting period + +### Common Error Messages + +If you see: + +- `Error: Cannot find module`: Check your build folder and ensure TypeScript compilation succeeded +- `Connection refused`: Ensure the server is running and the path is correct +- `Tool execution failed`: Verify the tool's required environment variables are set +- `ANTHROPIC_API_KEY is not set`: Check your environment variables (e.g., `export ANTHROPIC_API_KEY=...`) +- `TypeError`: Ensure you're using the correct types for tool arguments +- `BadRequestError`: Ensure you have enough credits to access the Anthropic API + +## Next steps + +Now that you have a working client, here are some ways to go further: + +- [**Client guide**](./client.md) — Add OAuth, middleware, sampling, and more to your client. +- [**Example clients**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Browse runnable client examples. +- [**FAQ**](./faq.md) — Troubleshoot common errors. diff --git a/docs/client.md b/docs/client.md new file mode 100644 index 0000000..0946eee --- /dev/null +++ b/docs/client.md @@ -0,0 +1,626 @@ +--- +title: Client Guide +--- + +# Building MCP clients + +This guide covers the TypeScript SDK APIs for building MCP clients. For protocol-level concepts, see the [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture). + +A client connects to a server, discovers what it offers — tools, resources, prompts — and invokes them. Beyond that core loop, this guide covers authentication, error handling, and responding to server-initiated requests like sampling and elicitation. + +## Imports + +The examples below use these imports. Adjust based on which features and transport you need: + +```ts source="../examples/client/src/clientGuide.examples.ts#imports" +import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import { + applyMiddlewares, + Client, + ClientCredentialsProvider, + createMiddleware, + CrossAppAccessProvider, + discoverAndRequestJwtAuthGrant, + PrivateKeyJwtProvider, + ProtocolError, + SdkError, + SdkErrorCode, + SSEClientTransport, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +``` + +## Connecting to a server + +### Streamable HTTP + +For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}: + +```ts source="../examples/client/src/clientGuide.examples.ts#connect_streamableHttp" +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + +await client.connect(transport); +``` + +For a full interactive client over Streamable HTTP, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). + +### stdio + +For local, process-spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout: + +```ts source="../examples/client/src/clientGuide.examples.ts#connect_stdio" +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'] +}); + +await client.connect(transport); +``` + +### SSE fallback for legacy servers + +To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure: + +```ts source="../examples/client/src/clientGuide.examples.ts#connect_sseFallback" +const baseUrl = new URL(url); + +try { + // Try modern Streamable HTTP transport first + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; +} catch { + // Fall back to legacy SSE transport + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new SSEClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; +} +``` + +For a complete example with error reporting, see [`streamableHttpWithSseFallbackClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/streamableHttpWithSseFallbackClient.ts). + +### Disconnecting + +Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error. + +For Streamable HTTP, terminate the server-side session first (per the MCP specification): + +```ts source="../examples/client/src/clientGuide.examples.ts#disconnect_streamableHttp" +await transport.terminateSession(); // notify the server (recommended) +await client.close(); +``` + +For stdio, `client.close()` handles graceful process shutdown (closes stdin, then SIGTERM, then SIGKILL if needed). + +### Server instructions + +Servers can provide an `instructions` string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Retrieve it after connecting and include it in the model's system prompt: + +```ts source="../examples/client/src/clientGuide.examples.ts#serverInstructions_basic" +const instructions = client.getInstructions(); + +const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); + +console.log(systemPrompt); +``` + +## Authentication + +MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once. + +### Bearer tokens + +For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only `token()`. With no `onUnauthorized()`, a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately: + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_tokenProvider" +const authProvider: AuthProvider = { token: async () => getStoredToken() }; + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); +``` + +See [`simpleTokenProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTokenProvider.ts) for a complete runnable example. + +### Client credentials + +{@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the `client_credentials` grant flow for service-to-service communication: + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_clientCredentials" +const authProvider = new ClientCredentialsProvider({ + clientId: 'my-service', + clientSecret: 'my-secret' +}); + +const client = new Client({ name: 'my-client', version: '1.0.0' }); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + +await client.connect(transport); +``` + +### Private key JWT + +{@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the `private_key_jwt` token endpoint auth method, avoiding a shared client secret: + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_privateKeyJwt" +const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); +``` + +For a runnable example supporting both auth methods via environment variables, see [`simpleClientCredentials.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleClientCredentials.ts). + +### Full OAuth with user authorization + +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. + +For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). + +### Cross-App Access (Enterprise Managed Authorization) + +{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf. + +This provider handles a two-step OAuth flow: +1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange +2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant + +```ts source="../examples/client/src/clientGuide.examples.ts#auth_crossAppAccess" +const authProvider = new CrossAppAccessProvider({ + assertion: async ctx => { + // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: ctx.authorizationServerUrl, + resource: ctx.resourceUrl, + idToken: await getIdToken(), + clientId: 'my-idp-client', + clientSecret: 'my-idp-secret', + scope: ctx.scope, + fetchFn: ctx.fetchFn + }); + return result.jwtAuthGrant; + }, + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret' +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); +``` + +The `assertion` callback receives a context object with: +- `authorizationServerUrl` – The MCP server's authorization server (discovered automatically) +- `resourceUrl` – The MCP resource URL (discovered automatically) +- `scope` – Optional scope passed to `auth()` or from `clientMetadata` +- `fetchFn` – Fetch implementation to use for HTTP requests + +For manual control over the token exchange steps, use the Layer 2 utilities from `@modelcontextprotocol/client`: +- `requestJwtAuthorizationGrant()` – Exchange ID Token for JAG at IdP +- `discoverAndRequestJwtAuthGrant()` – Discovery + JAG acquisition +- `exchangeJwtAuthGrant()` – Exchange JAG for access token at MCP server + +> [!NOTE] +> See [RFC 8693 (Token Exchange)](https://datatracker.ietf.org/doc/html/rfc8693), [RFC 7523 (JWT Bearer Grant)](https://datatracker.ietf.org/doc/html/rfc7523), and [RFC 9728 (Resource Discovery)](https://datatracker.ietf.org/doc/html/rfc9728) for the underlying OAuth standards. + +## Tools + +Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. Results may be paginated — loop on `nextCursor` to collect all pages: + +```ts source="../examples/client/src/clientGuide.examples.ts#callTool_basic" +const allTools: Tool[] = []; +let toolCursor: string | undefined; +do { + const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); + allTools.push(...tools); + toolCursor = nextCursor; +} while (toolCursor); +console.log( + 'Available tools:', + allTools.map(t => t.name) +); + +const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } +}); +console.log(result.content); +``` + +Tool results may include a `structuredContent` field — a machine-readable JSON object for programmatic use by the client application, complementing `content` which is for the LLM: + +```ts source="../examples/client/src/clientGuide.examples.ts#callTool_structuredOutput" +const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } +}); + +// Machine-readable output for the client application +if (result.structuredContent) { + console.log(result.structuredContent); // e.g. { bmi: 22.86 } +} +``` + +### Tracking progress + +Pass `onprogress` to receive incremental progress notifications from long-running tools. Use `resetTimeoutOnProgress` to keep the request alive while the server is actively reporting, and `maxTotalTimeout` as an absolute cap: + +```ts source="../examples/client/src/clientGuide.examples.ts#callTool_progress" +const result = await client.callTool( + { name: 'long-operation', arguments: {} }, + { + onprogress: ({ progress, total }: { progress: number; total?: number }) => { + console.log(`Progress: ${progress}/${total ?? '?'}`); + }, + resetTimeoutOnProgress: true, + maxTotalTimeout: 600_000 + } +); +console.log(result.content); +``` + +## Resources + +Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. Results may be paginated — loop on `nextCursor` to collect all pages: + +```ts source="../examples/client/src/clientGuide.examples.ts#readResource_basic" +const allResources: Resource[] = []; +let resourceCursor: string | undefined; +do { + const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); + allResources.push(...resources); + resourceCursor = nextCursor; +} while (resourceCursor); +console.log( + 'Available resources:', + allResources.map(r => r.name) +); + +const { contents } = await client.readResource({ uri: 'config://app' }); +for (const item of contents) { + console.log(item); +} +``` + +To discover URI templates for dynamic resources, use {@linkcode @modelcontextprotocol/client!client/client.Client#listResourceTemplates | listResourceTemplates()}. + +### Subscribing to resource changes + +If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it: + +```ts source="../examples/client/src/clientGuide.examples.ts#subscribeResource_basic" +await client.subscribeResource({ uri: 'config://app' }); + +client.setNotificationHandler('notifications/resources/updated', async notification => { + if (notification.params.uri === 'config://app') { + const { contents } = await client.readResource({ uri: 'config://app' }); + console.log('Config updated:', contents); + } +}); + +// Later: stop receiving updates +await client.unsubscribeResource({ uri: 'config://app' }); +``` + +## Prompts + +Prompts are reusable message templates that servers offer to help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). + +Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. Results may be paginated — loop on `nextCursor` to collect all pages: + +```ts source="../examples/client/src/clientGuide.examples.ts#getPrompt_basic" +const allPrompts: Prompt[] = []; +let promptCursor: string | undefined; +do { + const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); + allPrompts.push(...prompts); + promptCursor = nextCursor; +} while (promptCursor); +console.log( + 'Available prompts:', + allPrompts.map(p => p.name) +); + +const { messages } = await client.getPrompt({ + name: 'review-code', + arguments: { code: 'console.log("hello")' } +}); +console.log(messages); +``` + +## Completions + +Both prompts and resources can support argument completions. Use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request autocompletion suggestions from the server as a user types: + +```ts source="../examples/client/src/clientGuide.examples.ts#complete_basic" +const { completion } = await client.complete({ + ref: { + type: 'ref/prompt', + name: 'review-code' + }, + argument: { + name: 'language', + value: 'type' + } +}); +console.log(completion.values); // e.g. ['typescript'] +``` + +## Notifications + +### Automatic list-change tracking + +The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks: + +```ts source="../examples/client/src/clientGuide.examples.ts#listChanged_basic" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } +); +``` + +### Manual notification handlers + +For full control — or for notification types not covered by `listChanged` (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}: + +```ts source="../examples/client/src/clientGuide.examples.ts#notificationHandler_basic" +// Server log messages (sent by the server during request processing) +client.setNotificationHandler('notifications/message', notification => { + const { level, data } = notification.params; + console.log(`[${level}]`, data); +}); + +// Server's resource list changed — re-fetch the list +client.setNotificationHandler('notifications/resources/list_changed', async () => { + const { resources } = await client.listResources(); + console.log('Resources changed:', resources.length); +}); +``` + +To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}: + +```ts source="../examples/client/src/clientGuide.examples.ts#setLoggingLevel_basic" +await client.setLoggingLevel('warning'); +``` + +> [!WARNING] +> `listChanged` and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten. + +## Handling server-initiated requests + +MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler: + +```ts source="../examples/client/src/clientGuide.examples.ts#capabilities_declaration" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {} } + } + } +); +``` + +### Sampling + +When a server needs an LLM completion during tool execution, it sends a `sampling/createMessage` request to the client (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Register a handler to fulfill it: + +```ts source="../examples/client/src/clientGuide.examples.ts#sampling_handler" +client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { + type: 'text' as const, + text: 'Response from the model' + } + }; +}); +``` + +### Elicitation + +When a server needs user input during tool execution, it sends an `elicitation/create` request to the client (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). The client should present the form to the user and return the collected data, or `{ action: 'decline' }`: + +```ts source="../examples/client/src/clientGuide.examples.ts#elicitation_handler" +client.setRequestHandler('elicitation/create', async request => { + console.log('Server asks:', request.params.message); + + if (request.params.mode === 'form') { + // Present the schema-driven form to the user + console.log('Schema:', request.params.requestedSchema); + return { action: 'accept', content: { confirm: true } }; + } + + return { action: 'decline' }; +}); +``` + +For a full form-based elicitation handler with AJV validation, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleStreamableHttp.ts). For URL elicitation mode, see [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts). + +### Roots + +Roots let the client expose filesystem boundaries to the server (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Declare the `roots` capability and register a `roots/list` handler: + +```ts source="../examples/client/src/clientGuide.examples.ts#roots_handler" +client.setRequestHandler('roots/list', async () => { + return { + roots: [ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' } + ] + }; +}); +``` + +When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}. + +## Error handling + +### Tool errors vs protocol errors + +{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can *run but report failure* via `isError: true` in the result, or the *request itself can fail* and throw an exception. Always check both: + +```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_toolErrors" +try { + const result = await client.callTool({ + name: 'fetch-data', + arguments: { url: 'https://example.com' } + }); + + // Tool-level error: the tool ran but reported a problem + if (result.isError) { + console.error('Tool error:', result.content); + return; + } + + console.log('Success:', result.content); +} catch (error) { + // Protocol-level error: the request itself failed + if (error instanceof ProtocolError) { + console.error(`Protocol error ${error.code}: ${error.message}`); + } else if (error instanceof SdkError) { + console.error(`SDK error [${error.code}]: ${error.message}`); + } else { + throw error; + } +} +``` + +{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | CAPABILITY_NOT_SUPPORTED}, and others. + +### Connection lifecycle + +Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error: + +```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_lifecycle" +// Out-of-band errors (SSE disconnects, parse errors) +client.onerror = error => { + console.error('Transport error:', error.message); +}; + +// Connection closed (pending requests are rejected with CONNECTION_CLOSED) +client.onclose = () => { + console.log('Connection closed'); +}; +``` + +### Timeouts + +All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: + +```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" +try { + const result = await client.callTool( + { name: 'slow-task', arguments: {} }, + { timeout: 120_000 } // 2 minutes instead of the default 60 seconds + ); + console.log(result.content); +} catch (error) { + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + console.error('Request timed out'); + } +} +``` + +## Client middleware + +Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying `fetch` call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the `fetch` option: + +```ts source="../examples/client/src/clientGuide.examples.ts#middleware_basic" +const authMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'my-value'); + return next(input, { ...init, headers }); +}); + +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(authMiddleware)(fetch) +}); +``` + +## Resumption tokens + +When using SSE-based streaming, the server can assign event IDs. Pass `onresumptiontoken` to track them, and `resumptionToken` to resume from where you left off after a disconnection: + +```ts source="../examples/client/src/clientGuide.examples.ts#resumptionToken_basic" +let lastToken: string | undefined; + +const result = await client.request( + { + method: 'tools/call', + params: { name: 'long-running-task', arguments: {} } + }, + { + resumptionToken: lastToken, + onresumptiontoken: (token: string) => { + lastToken = token; + // Persist token to survive restarts + } + } +); +console.log(result); +``` + +For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts). + +## Tasks (experimental) + +> [!WARNING] +> The tasks API is experimental and may change without notice. + +Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks: + +- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#callToolStream | client.experimental.tasks.callToolStream(...)} to start a tool call that may create a task and emit status updates over time. +- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTask | client.experimental.tasks.getTask(...)} and {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTaskResult | getTaskResult(...)} to check status and fetch results after reconnecting. + +For a full runnable example, see [`simpleTaskInteractiveClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTaskInteractiveClient.ts). + +## See also + +- [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples +- [Server guide](./server.md) — Building MCP servers with this SDK +- [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives +- [Migration guide](./migration.md) — Upgrading from previous SDK versions +- [FAQ](./faq.md) — Frequently asked questions and troubleshooting + +### Additional examples + +| Feature | Description | Example | +|---------|-------------|---------| +| Parallel tool calls | Run multiple tool calls concurrently via `Promise.all` | [`parallelToolCallsClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/parallelToolCallsClient.ts) | +| SSE disconnect / reconnection | Server-initiated SSE disconnect with automatic reconnection and event replay | [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts) | +| Multiple clients | Independent client lifecycles to the same server | [`multipleClientsParallel.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/multipleClientsParallel.ts) | +| URL elicitation | Handle sensitive data collection via browser | [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/elicitationUrlExample.ts) | diff --git a/docs/documents.md b/docs/documents.md new file mode 100644 index 0000000..65cff97 --- /dev/null +++ b/docs/documents.md @@ -0,0 +1,17 @@ +--- +title: Documents +children: + - ./server-quickstart.md + - ./server.md + - ./client-quickstart.md + - ./client.md + - ./faq.md +--- + +# Documents + +- [Server Quickstart](./server-quickstart.md) – build a weather server from scratch and connect it to VS Code +- [Server](./server.md) – building MCP servers: transports, tools, resources, prompts, server-initiated requests, and deployment +- [Client Quickstart](./client-quickstart.md) – build an LLM-powered chatbot that connects to an MCP server and calls its tools +- [Client](./client.md) – building MCP clients: connecting, tools, resources, prompts, server-initiated requests, and error handling +- [FAQ](./faq.md) – frequently asked questions and troubleshooting diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..ab237f6 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,85 @@ +--- +title: FAQ +--- + +## FAQ + +
+Table of Contents + +- [General](#general) +- [Clients](#clients) +- [Servers](#servers) +- [v1 (legacy)](#v1-legacy) + +
+ +## General + +### Why do I see `TS2589: Type instantiation is excessively deep and possibly infinite` after upgrading the SDK? + +This TypeScript error can appear when upgrading to newer SDK versions that support Zod v4 (for example, from older `@modelcontextprotocol/sdk` releases to newer `@modelcontextprotocol/client` / `@modelcontextprotocol/server` releases) **and** your project ends up with multiple +`zod` versions in the dependency tree. + +When there are multiple copies or versions of `zod`, TypeScript may try to instantiate very complex, cross-version types and hit its recursion limits, resulting in `TS2589`. This scenario is discussed in GitHub issue +[#1180](https://github.com/modelcontextprotocol/typescript-sdk/issues/1180#event-21236550401). + +To diagnose and fix this: + +- **Inspect your installed `zod` versions**: + - Run `npm ls zod` or `npm explain zod`, `pnpm list zod` or `pnpm why zod`, or `yarn why zod` and check whether more than one version is installed. +- **Align on a single `zod` version**: + - Make sure all packages that depend on `zod` use a compatible version range so that your package manager can hoist a single copy. + - In monorepos, consider declaring `zod` at the workspace root and using compatible ranges in individual packages. +- **Use overrides/resolutions if necessary**: + - With npm, Yarn, or pnpm, you can use `overrides` / `resolutions` to force a single `zod` version if some transitive dependencies pull in a different one. + +Once your project is using a single, compatible `zod` version, the `TS2589` error should no longer occur. + +## Clients + +### How do I enable Web Crypto (`globalThis.crypto`) for client authentication in older Node.js versions? + +The SDK’s OAuth client authentication helpers (for example, those in `packages/client/src/client/auth-extensions.ts` that use `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. This is especially important for **client credentials** and **JWT-based** +authentication flows used by MCP clients. + +- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. +- **Node.js v18.x**: `globalThis.crypto` may not be defined by default. In this repository we polyfill it for tests (see `packages/client/vitest.setup.js`), and you should do the same in your app if it is missing – or alternatively, run Node with `--experimental-global-webcrypto` + as per your Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) + +If you run clients on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`: + +```typescript +import { webcrypto } from 'node:crypto'; + +if (typeof globalThis.crypto === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).crypto = webcrypto as unknown as Crypto; +} +``` + +For production use, you can either: + +- Run clients on a Node.js version where `globalThis.crypto` is available by default (recommended), or +- Apply a similar polyfill early in your client's startup code when targeting older Node.js runtimes, so that OAuth client authentication works reliably. + +## Servers + +### Where can I find runnable server examples? + +The [server quickstart](./server-quickstart.md) walks you through building a weather server from scratch. Its complete source lives in [`examples/server-quickstart/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart/). For more advanced examples (OAuth, streaming, sessions, etc.), see the server examples index in [`examples/server/README.md`](../examples/server/README.md). + +### Where are the server auth helpers? + +Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. Example packages provide a demo with `better-auth`. + +### Why did we remove `server` SSE transport? + +The SSE transport has been deprecated for a long time, and `v2` will not support it on the server side any more. Client side will keep supporting it in order to be able to connect to legacy SSE servers via the `v2` SDK, but serving SSE from `v2` will not be possible. Servers +wanting to switch to `v2` and using SSE should migrate to Streamable HTTP. + +## v1 (legacy) + +### Where do v1 documentation and v1-specific fixes live? + +The v1 API documentation is available at [`https://ts.sdk.modelcontextprotocol.io/`](https://ts.sdk.modelcontextprotocol.io/). The v1 source code and any v1-specific fixes live on the long-lived [`v1.x` branch](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x). V2 API docs are at [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md new file mode 100644 index 0000000..e2fbf71 --- /dev/null +++ b/docs/migration-SKILL.md @@ -0,0 +1,548 @@ +--- +name: migrate-v1-to-v2 +description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. +--- + +# MCP TypeScript SDK: v1 → v2 Migration + +Apply these changes in order: dependencies → imports → API calls → type aliases. + +## 1. Environment + +- Node.js 20+ required (v18 dropped) +- ESM only (CJS dropped). If the project uses `require()`, convert to `import`/`export` or use dynamic `import()`. + +## 2. Dependencies + +Remove the old package and install only what you need: + +```bash +npm uninstall @modelcontextprotocol/sdk +``` + +| You need | Install | +| --------------------- | ------------------------------------------------------------------------ | +| Client only | `npm install @modelcontextprotocol/client` | +| Server only | `npm install @modelcontextprotocol/server` | +| Server + Node.js HTTP | `npm install @modelcontextprotocol/server @modelcontextprotocol/node` | +| Server + Express | `npm install @modelcontextprotocol/server @modelcontextprotocol/express` | +| Server + Hono | `npm install @modelcontextprotocol/server @modelcontextprotocol/hono` | + +`@modelcontextprotocol/core` is installed automatically as a dependency. + +## 3. Import Mapping + +Replace all `@modelcontextprotocol/sdk/...` imports using this table. + +### Client imports + +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------------------------------------------------------ | +| `@modelcontextprotocol/sdk/client/index.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/auth.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/streamableHttp.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/sse.js` | `@modelcontextprotocol/client` | +| `@modelcontextprotocol/sdk/client/stdio.js` | `@modelcontextprotocol/client/stdio` | +| `@modelcontextprotocol/sdk/client/websocket.js` | REMOVED (use Streamable HTTP or stdio; implement `Transport` for custom needs) | + +### Server imports + +| v1 import path | v2 package | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | +| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) | +| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP) | +| `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers removed (use external IdP/OAuth library) | +| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) | + +### Types / shared imports + +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------------------------------------------- | +| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/stdio.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` (`ReadBuffer`, `serializeMessage`, `deserializeMessage` are in the root barrel; the `./stdio` subpath only has the transport class) | + +Notes: + +- `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so import from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly — it is an internal package. +- When multiple v1 imports map to the same v2 package, consolidate them into a single import statement. + +## 4. Renamed Symbols + +| v1 symbol | v2 symbol | v2 package | +| ------------------------------- | ----------------------------------- | ---------------------------- | +| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` | + +## 5. Removed / Renamed Type Aliases and Symbols + +| v1 (removed) | v2 (replacement) | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` (**not** v2's new `isJSONRPCResponse`, which correctly matches both result and error) | +| `ResourceReference` | `ResourceTemplateReference` | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `IsomorphicHeaders` | REMOVED (use Web Standard `Headers`) | +| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | +| `StreamableHTTPError` | REMOVED (use `SdkHttpError` with `SdkErrorCode.ClientHttp*`) | +| `WebSocketClientTransport` | REMOVED (use `StreamableHTTPClientTransport` or `StdioClientTransport`) | + +All other **type** symbols from `@modelcontextprotocol/sdk/types.js` retain their original names. **Zod schemas** (e.g., `CallToolResultSchema`, `ListToolsResultSchema`) are no longer part of the public API — they are internal to the SDK. For runtime validation, use +`isSpecType.TypeName(value)` (e.g., `isSpecType.CallToolResult(v)`) or `specTypeSchemas.TypeName` for the `StandardSchemaV1Sync` validator object. The keys are typed as `SpecTypeName`, a literal union of all spec type names. + +### Error class changes + +Three error classes now exist: + +- **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC responses +- **`SdkError`** (new): Local SDK errors that never cross the wire +- **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors + +| Error scenario | v1 type | v2 type | +| --------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- | +| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` | +| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` | +| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | +| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | +| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | +| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | +| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | +| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | +| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | + +New `SdkErrorCode` enum values: + +- `SdkErrorCode.NotConnected` = `'NOT_CONNECTED'` +- `SdkErrorCode.AlreadyConnected` = `'ALREADY_CONNECTED'` +- `SdkErrorCode.NotInitialized` = `'NOT_INITIALIZED'` +- `SdkErrorCode.CapabilityNotSupported` = `'CAPABILITY_NOT_SUPPORTED'` +- `SdkErrorCode.RequestTimeout` = `'REQUEST_TIMEOUT'` +- `SdkErrorCode.ConnectionClosed` = `'CONNECTION_CLOSED'` +- `SdkErrorCode.SendFailed` = `'SEND_FAILED'` +- `SdkErrorCode.InvalidResult` = `'INVALID_RESULT'` +- `SdkErrorCode.ClientHttpNotImplemented` = `'CLIENT_HTTP_NOT_IMPLEMENTED'` +- `SdkErrorCode.ClientHttpAuthentication` = `'CLIENT_HTTP_AUTHENTICATION'` +- `SdkErrorCode.ClientHttpForbidden` = `'CLIENT_HTTP_FORBIDDEN'` +- `SdkErrorCode.ClientHttpUnexpectedContent` = `'CLIENT_HTTP_UNEXPECTED_CONTENT'` +- `SdkErrorCode.ClientHttpFailedToOpenStream` = `'CLIENT_HTTP_FAILED_TO_OPEN_STREAM'` +- `SdkErrorCode.ClientHttpFailedToTerminateSession` = `'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION'` + +Update error handling: + +```typescript +// v1 +if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } + +// v2 +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } +``` + +Update HTTP transport error handling: + +```typescript +// v1 +import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +if (error instanceof StreamableHTTPError) { + console.log('HTTP status:', error.code); +} + +// v2 +import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status); // number — typed accessor + console.log('Status text:', error.statusText); // string | undefined + switch (error.code) { + case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth + case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping + case SdkErrorCode.ClientHttpFailedToOpenStream: + case SdkErrorCode.ClientHttpNotImplemented: + break; + } +} +``` + +### OAuth error consolidation + +Individual OAuth error classes replaced with single `OAuthError` class and `OAuthErrorCode` enum: + +| v1 Class | v2 Equivalent | +| ------------------------------ | ---------------------------------------------------------- | +| `InvalidRequestError` | `OAuthError` with `OAuthErrorCode.InvalidRequest` | +| `InvalidClientError` | `OAuthError` with `OAuthErrorCode.InvalidClient` | +| `InvalidGrantError` | `OAuthError` with `OAuthErrorCode.InvalidGrant` | +| `UnauthorizedClientError` | `OAuthError` with `OAuthErrorCode.UnauthorizedClient` | +| `UnsupportedGrantTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedGrantType` | +| `InvalidScopeError` | `OAuthError` with `OAuthErrorCode.InvalidScope` | +| `AccessDeniedError` | `OAuthError` with `OAuthErrorCode.AccessDenied` | +| `ServerError` | `OAuthError` with `OAuthErrorCode.ServerError` | +| `TemporarilyUnavailableError` | `OAuthError` with `OAuthErrorCode.TemporarilyUnavailable` | +| `UnsupportedResponseTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedResponseType` | +| `UnsupportedTokenTypeError` | `OAuthError` with `OAuthErrorCode.UnsupportedTokenType` | +| `InvalidTokenError` | `OAuthError` with `OAuthErrorCode.InvalidToken` | +| `MethodNotAllowedError` | `OAuthError` with `OAuthErrorCode.MethodNotAllowed` | +| `TooManyRequestsError` | `OAuthError` with `OAuthErrorCode.TooManyRequests` | +| `InvalidClientMetadataError` | `OAuthError` with `OAuthErrorCode.InvalidClientMetadata` | +| `InsufficientScopeError` | `OAuthError` with `OAuthErrorCode.InsufficientScope` | +| `InvalidTargetError` | `OAuthError` with `OAuthErrorCode.InvalidTarget` | +| `CustomOAuthError` | `new OAuthError(customCode, message)` | + +Removed: `OAUTH_ERRORS` constant. + +Update OAuth error handling: + +```typescript +// v1 +import { InvalidClientError, InvalidGrantError } from '@modelcontextprotocol/client'; +if (error instanceof InvalidClientError) { ... } + +// v2 +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; +if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } +``` + +**Unchanged APIs** (only import paths changed): `Client` constructor and most methods, `McpServer` constructor, `server.connect()`, `server.close()`, all client transports (`StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport`), `StdioServerTransport`, all +Zod schemas, all callback return types. Note: `callTool()` and `request()` signatures changed (schema parameter removed, see section 11). + +## 6. McpServer API Changes + +The variadic `.tool()`, `.prompt()`, `.resource()` methods are removed. Use the `register*` methods with a config object. + +**IMPORTANT**: v2 requires schema objects implementing [Standard Schema](https://standardschema.dev/) — raw shapes like `{ name: z.string() }` are no longer supported. Wrap with `z.object()` (Zod v4), or use ArkType's `type({...})`, or Valibot. For raw JSON Schema, wrap with +`fromJsonSchema(schema)` from `@modelcontextprotocol/server` (validator defaults automatically; pass an explicit validator for custom configurations). Applies to `inputSchema`, `outputSchema`, and `argsSchema`. + +### Tools + +```typescript +// v1: server.tool(name, schema, callback) - raw shape worked +server.tool('greet', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// v1: server.tool(name, description, schema, callback) +server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// v2: server.registerTool(name, config, callback) +server.registerTool( + 'greet', + { + description: 'Greet a user', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } +); +``` + +Config object fields: `title?`, `description?`, `inputSchema?`, `outputSchema?`, `annotations?`, `_meta?` + +### Prompts + +```typescript +// v1: server.prompt(name, schema, callback) - raw shape worked +server.prompt('summarize', { text: z.string() }, async ({ text }) => { + return { messages: [{ role: 'user', content: { type: 'text', text } }] }; +}); + +// v2: server.registerPrompt(name, config, callback) +server.registerPrompt( + 'summarize', + { + argsSchema: z.object({ text: z.string() }) + }, + async ({ text }) => { + return { messages: [{ role: 'user', content: { type: 'text', text } }] }; + } +); +``` + +Config object fields: `title?`, `description?`, `argsSchema?` + +### Resources + +```typescript +// v1: server.resource(name, uri, callback) +server.resource('config', 'config://app', async uri => { + return { contents: [{ uri: uri.href, text: '{}' }] }; +}); + +// v2: server.registerResource(name, uri, metadata, callback) +server.registerResource('config', 'config://app', {}, async uri => { + return { contents: [{ uri: uri.href, text: '{}' }] }; +}); +``` + +Note: the third argument (`metadata`) is required — pass `{}` if no metadata. + +### Schema Migration Quick Reference + +| v1 (raw shape) | v2 (Standard Schema object) | +| ---------------------------------- | -------------------------------------------- | +| `{ name: z.string() }` | `z.object({ name: z.string() })` | +| `{ count: z.number().optional() }` | `z.object({ count: z.number().optional() })` | +| `{}` (empty) | `z.object({})` | +| `undefined` (no schema) | `undefined` or omit the field | + +### Removed core exports + +| Removed from `@modelcontextprotocol/core` | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | none (internal Zod introspection helpers) | + +## 7. Headers API + +Transport constructors now use the Web Standard `Headers` object instead of plain objects. The custom `RequestInfo` type has been replaced with the standard Web `Request` object, giving access to headers, URL, query parameters, and method. + +```typescript +// v1: plain object, bracket access, custom RequestInfo +headers: { 'Authorization': 'Bearer token' } +extra.requestInfo?.headers['mcp-session-id'] + +// v2: Headers object, .get() access, standard Web Request +headers: new Headers({ 'Authorization': 'Bearer token' }) +ctx.http?.req?.headers.get('mcp-session-id') +new URL(ctx.http?.req?.url).searchParams.get('debug') +``` + +## 8. Removed Server Features + +### SSE server transport + +`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. + +### Server-side auth + +Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are removed from the core SDK; use an external IdP/OAuth library. See `examples/server/src/` for demos. + +### Host header validation (Express) + +`hostHeaderValidation()` and `localhostHostValidation()` moved from server package to `@modelcontextprotocol/express`. Signature changed: takes `string[]` instead of options object. + +```typescript +// v1 +import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js'; +app.use(hostHeaderValidation({ allowedHosts: ['example.com'] })); + +// v2 +import { hostHeaderValidation } from '@modelcontextprotocol/express'; +app.use(hostHeaderValidation(['example.com'])); +``` + +The server package now exports framework-agnostic alternatives: `validateHostHeader()`, `localhostAllowedHostnames()`, `hostHeaderValidationResponse()`. + +## 9. `setRequestHandler` / `setNotificationHandler` API + +The low-level handler registration methods now take a method string instead of a Zod schema. + +```typescript +// v1: schema-based +server.setRequestHandler(InitializeRequestSchema, async (request) => { ... }); +server.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { ... }); + +// v2: method string +server.setRequestHandler('initialize', async (request) => { ... }); +server.setNotificationHandler('notifications/message', (notification) => { ... }); +``` + +For custom (non-spec) methods, use the 3-arg form `(method, schemas, handler)`: + +```typescript +// v1: Zod schema with method literal +server.setRequestHandler(z.object({ method: z.literal('acme/search'), params: P }), async req => { ... }); + +// v2: method string + schemas object; handler receives parsed params +server.setRequestHandler('acme/search', { params: P, result: R }, async (params, ctx) => { ... }); +client.setNotificationHandler('acme/progress', { params: P }, (params, notification) => { ... }); +``` + +The 3-arg notification handler receives the raw notification as its second argument, so `_meta` is recoverable via `notification.params?._meta`. + +To send a custom-method request, pass a result schema as the second argument to `request()` (and `ctx.mcpReq.send()`): + +```typescript +// v1 +await client.request({ method: 'acme/search', params }, ResultSchema); +// v2 (unchanged; now any Standard Schema, not Zod-only) +await client.request({ method: 'acme/search', params }, ResultSchema); +``` + +Schema to method string mapping: + +| v1 Schema | v2 Method String | +| --------------------------------------- | ---------------------------------------- | +| `InitializeRequestSchema` | `'initialize'` | +| `CallToolRequestSchema` | `'tools/call'` | +| `ListToolsRequestSchema` | `'tools/list'` | +| `ListPromptsRequestSchema` | `'prompts/list'` | +| `GetPromptRequestSchema` | `'prompts/get'` | +| `ListResourcesRequestSchema` | `'resources/list'` | +| `ReadResourceRequestSchema` | `'resources/read'` | +| `CreateMessageRequestSchema` | `'sampling/createMessage'` | +| `ElicitRequestSchema` | `'elicitation/create'` | +| `SetLevelRequestSchema` | `'logging/setLevel'` | +| `PingRequestSchema` | `'ping'` | +| `LoggingMessageNotificationSchema` | `'notifications/message'` | +| `ToolListChangedNotificationSchema` | `'notifications/tools/list_changed'` | +| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | +| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | +| `ProgressNotificationSchema` | `'notifications/progress'` | +| `CancelledNotificationSchema` | `'notifications/cancelled'` | +| `InitializedNotificationSchema` | `'notifications/initialized'` | + +Request/notification params remain fully typed. Remove unused schema imports after migration. + +## 10. Request Handler Context Types + +`RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks. + +| v1 | v2 | +| -------------------------------- | -------------------------------------------------------------------------- | +| `RequestHandlerExtra` | `ServerContext` (server) / `ClientContext` (client) / `BaseContext` (base) | +| `extra` (param name) | `ctx` | +| `extra.signal` | `ctx.mcpReq.signal` | +| `extra.requestId` | `ctx.mcpReq.id` | +| `extra._meta` | `ctx.mcpReq._meta` | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | +| `extra.authInfo` | `ctx.http?.authInfo` | +| `extra.sessionId` | `ctx.sessionId` | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) | +| `extra.taskStore` | `ctx.task?.store` | +| `extra.taskId` | `ctx.task?.id` | +| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` | + +`ServerContext` convenience methods (new in v2, no v1 equivalent): + +| Method | Description | Replaces | +| ---------------------------------------------- | ------------------------------------------------------ | ---------------------------------------------------- | +| `ctx.mcpReq.log(level, data, logger?)` | Send log notification (respects client's level filter) | `server.sendLoggingMessage(...)` from within handler | +| `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler | +| `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler | + +## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods) + +For **spec** methods, `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer require a Zod result schema argument. The SDK resolves the schema internally from the method name. + +```typescript +// v1: schema required +import { CallToolResultSchema, ElicitResultSchema } from '@modelcontextprotocol/sdk/types.js'; +const result = await client.request({ method: 'tools/call', params: { ... } }, CallToolResultSchema); +const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }, ElicitResultSchema); +const tool = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); + +// v2: no schema argument +const result = await client.request({ method: 'tools/call', params: { ... } }); +const elicit = await ctx.mcpReq.send({ method: 'elicitation/create', params: { ... } }); +const tool = await client.callTool({ name: 'my-tool', arguments: {} }); +``` + +| v1 call | v2 call | +| ------------------------------------------------------------ | ---------------------------------- | +| `client.request(req, ResultSchema)` | `client.request(req)` | +| `client.request(req, ResultSchema, options)` | `client.request(req, options)` | +| `ctx.mcpReq.send(req, ResultSchema)` | `ctx.mcpReq.send(req)` | +| `ctx.mcpReq.send(req, ResultSchema, options)` | `ctx.mcpReq.send(req, options)` | +| `client.callTool(params, CompatibilityCallToolResultSchema)` | `client.callTool(params)` | +| `client.callTool(params, schema, options)` | `client.callTool(params, options)` | + +For **custom (non-spec)** methods, keep the result-schema argument — see §9. Only apply the rewrites above when `req.method` is a spec method. + +Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResultSchema`, `ElicitResultSchema`, `CreateMessageResultSchema`, etc., when they were only used in `request()`/`send()`/`callTool()` calls. + +If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`: + +| v1 pattern | v2 replacement | +| -------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | +| `Schema.safeParse(value).success` | `isSpecType.(value)` | +| `Schema.parse(value)` | `specTypeSchemas.['~standard'].validate(value)` (returns a `Result` synchronously, not the value) | +| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | + +`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. + +## 12. Experimental: `TaskCreationParams.ttl` no longer accepts `null` + +`TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide. + +| v1 | v2 | +| ---------------------- | ---------------------------------- | +| `task: { ttl: null }` | `task: {}` (omit ttl) | +| `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) | + +Type changes in handler context: + +| Type | v1 | v2 | +| ------------------------------------------- | ----------------------------- | --------------------- | +| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | +| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` | + +> These task APIs are `@experimental` and may change without notice. + +## 13. Client Behavioral Changes + +`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. + +## 14. Runtime-Specific JSON Schema Validators (Enhancement) + +The SDK now auto-selects the appropriate JSON Schema validator based on runtime: + +- Node.js → `AjvJsonSchemaValidator` (no change from v1) +- Cloudflare Workers (workerd) → `CfWorkerJsonSchemaValidator` (previously required manual config) + +**No action required** for most users. Cloudflare Workers users can remove explicit `jsonSchemaValidator` configuration: + +```typescript +// v1 (Cloudflare Workers): Required explicit validator +new McpServer( + { name: 'server', version: '1.0.0' }, + { + jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + } +); + +// v2 (Cloudflare Workers): Auto-selected, explicit config optional +new McpServer({ name: 'server', version: '1.0.0' }, {}); +``` + +Access validators explicitly: + +- Runtime-aware default: `import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';` +- AJV (Node.js): `import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server';` +- CF Worker: `import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';` + +## 15. Migration Steps (apply in this order) + +1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages +2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport` → `NodeStreamableHTTPServerTransport` +3. Replace removed type aliases (`JSONRPCError` → `JSONRPCErrorResponse`, etc.) per section 5 +4. Replace `.tool()` / `.prompt()` / `.resource()` calls with `registerTool` / `registerPrompt` / `registerResource` per section 6 +5. **Wrap all raw Zod shapes with `z.object()`**: Change `inputSchema: { name: z.string() }` → `inputSchema: z.object({ name: z.string() })`. Same for `outputSchema` in tools and `argsSchema` in prompts. +6. Replace plain header objects with `new Headers({...})` and bracket access (`headers['x']`) with `.get()` calls per section 7 +7. If using `hostHeaderValidation` from server, update import and signature per section 8 +8. If using server SSE transport, migrate to Streamable HTTP +9. If using server auth from the SDK: RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`) → `@modelcontextprotocol/express`; AS helpers → external IdP/OAuth library +10. If relying on `listTools()`/`listPrompts()`/etc. throwing on missing capabilities, set `enforceStrictCapabilities: true` +11. Verify: build with `tsc` / run tests diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..9fd029e --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,970 @@ +# Migration Guide: v1 to v2 + +This guide covers the breaking changes introduced in v2 of the MCP TypeScript SDK and how to update your code. + +## Overview + +Version 2 of the MCP TypeScript SDK introduces several breaking changes to improve modularity, reduce dependency bloat, and provide a cleaner API surface. The biggest change is the split from a single `@modelcontextprotocol/sdk` package into separate `@modelcontextprotocol/core`, +`@modelcontextprotocol/client`, and `@modelcontextprotocol/server` packages. + +## Breaking Changes + +### Package split (monorepo) + +The single `@modelcontextprotocol/sdk` package has been split into three packages: + +| v1 | v2 | +| --------------------------- | ---------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/core` (types, protocol, transports) | +| | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | + +Remove the old package and install only the packages you need: + +```bash +npm uninstall @modelcontextprotocol/sdk + +# If you only need a client +npm install @modelcontextprotocol/client + +# If you only need a server +npm install @modelcontextprotocol/server + +# Both packages depend on @modelcontextprotocol/core automatically +``` + +Update your imports accordingly: + +**Before (v1):** + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +``` + +**After (v2):** + +```typescript +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +// Node.js HTTP server transport is in the @modelcontextprotocol/node package +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +``` + +Note: `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core`, so you can import types and error classes from whichever package you already depend on. Do not import from `@modelcontextprotocol/core` directly +— it is an internal package. + +### Dropped Node.js 18 and CommonJS + +v2 requires **Node.js 20+** and ships **ESM only** (no more CommonJS builds). + +If your project uses CommonJS (`require()`), you will need to either: + +- Migrate to ESM (`import`/`export`) +- Use dynamic `import()` to load the SDK + +### Server decoupled from HTTP frameworks + +The server package no longer depends on Express or Hono. HTTP framework integrations are now separate middleware packages: + +| v1 | v2 | +| -------------------------------------- | ------------------------------------------- | +| Built into `@modelcontextprotocol/sdk` | `@modelcontextprotocol/node` (Node.js HTTP) | +| | `@modelcontextprotocol/express` (Express) | +| | `@modelcontextprotocol/hono` (Hono) | + +Install the middleware package for your framework: + +```bash +npm install @modelcontextprotocol/node # Node.js native http +npm install @modelcontextprotocol/express # Express +npm install @modelcontextprotocol/hono # Hono +``` + +### `StreamableHTTPServerTransport` renamed + +`StreamableHTTPServerTransport` has been renamed to `NodeStreamableHTTPServerTransport` and moved to `@modelcontextprotocol/node`. + +**Before (v1):** + +```typescript +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); +``` + +**After (v2):** + +```typescript +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; + +const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); +``` + +### Server-side SSE transport removed + +The SSE transport has been removed from the server. Servers should migrate to Streamable HTTP. The client-side SSE transport remains available for connecting to legacy SSE servers. + +### `WebSocketClientTransport` removed + +`WebSocketClientTransport` has been removed. WebSocket is not a spec-defined MCP transport, and keeping it in the SDK encouraged transport proliferation without a conformance baseline. + +Use `StdioClientTransport` for local servers or `StreamableHTTPClientTransport` for remote servers. If you need WebSocket for a custom deployment, implement the `Transport` interface directly — it remains exported from `@modelcontextprotocol/client`. + +**Before (v1):** + +```typescript +import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js'; +const transport = new WebSocketClientTransport(new URL('ws://localhost:3000')); +``` + +**After (v2):** + +```typescript +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); +``` + +### Server auth split + +Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are now first-class in `@modelcontextprotocol/express`. + +Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for a working demo with `better-auth`. + +Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. + +### `Headers` object instead of plain objects + +Transport APIs and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain `Record` (`IsomorphicHeaders` has been removed). + +This affects both transport constructors and request handler code that reads headers: + +**Before (v1):** + +```typescript +// Transport headers +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { + headers: { + Authorization: 'Bearer token', + 'X-Custom': 'value' + } + } +}); + +// Reading headers in a request handler +const sessionId = extra.requestInfo?.headers['mcp-session-id']; +``` + +**After (v2):** + +```typescript +// Transport headers +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { + headers: new Headers({ + Authorization: 'Bearer token', + 'X-Custom': 'value' + }) + } +}); + +// Reading headers in a request handler (ctx.http.req is the standard Web Request object) +const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); + +// Reading query parameters +const url = new URL(ctx.http!.req!.url); +const debug = url.searchParams.get('debug'); +``` + +### `McpServer.tool()`, `.prompt()`, `.resource()` removed + +The deprecated variadic-overload methods have been removed. Use `registerTool`, `registerPrompt`, and `registerResource` instead. These use an explicit config object rather than positional arguments. + +**Before (v1):** + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +const server = new McpServer({ name: 'demo', version: '1.0.0' }); + +// Tool with schema +server.tool('greet', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// Tool with description +server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// Prompt +server.prompt('summarize', { text: z.string() }, async ({ text }) => { + return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }] }; +}); + +// Resource +server.resource('config', 'config://app', async uri => { + return { contents: [{ uri: uri.href, text: '{}' }] }; +}); +``` + +**After (v2):** + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +const server = new McpServer({ name: 'demo', version: '1.0.0' }); + +// Tool with schema +server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// Tool with description +server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; +}); + +// Prompt +server.registerPrompt('summarize', { argsSchema: z.object({ text: z.string() }) }, async ({ text }) => { + return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize: ${text}` } }] }; +}); + +// Resource +server.registerResource('config', 'config://app', {}, async uri => { + return { contents: [{ uri: uri.href, text: '{}' }] }; +}); +``` + +### Standard Schema objects required (raw shapes no longer supported) + +v2 requires schema objects implementing the [Standard Schema spec](https://standardschema.dev/) for `inputSchema`, `outputSchema`, and `argsSchema`. Raw object shapes are no longer accepted. Zod v4, ArkType, and Valibot all implement the spec. + +**Before (v1):** + +```typescript +// Raw shape (object with Zod fields) - worked in v1 +server.tool('greet', { name: z.string() }, async ({ name }) => { ... }); + +server.registerTool('greet', { + inputSchema: { name: z.string() } // raw shape +}, callback); +``` + +**After (v2):** + +```typescript +import * as z from 'zod/v4'; + +// Wrap with z.object() (or use any Standard Schema library) +server.registerTool('greet', { + inputSchema: z.object({ name: z.string() }) +}, async ({ name }) => { ... }); + +// ArkType works too +import { type } from 'arktype'; +server.registerTool('greet', { + inputSchema: type({ name: 'string' }) +}, async ({ name }) => { ... }); + +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; +server.registerTool('greet', { + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) +}, handler); + +// For tools with no parameters, use z.object({}) +server.registerTool('ping', { + inputSchema: z.object({}) +}, async () => { ... }); +``` + +This applies to: + +- `inputSchema` in `registerTool()` +- `outputSchema` in `registerTool()` +- `argsSchema` in `registerPrompt()` + +**Removed Zod-specific helpers** from `@modelcontextprotocol/core` (use Standard Schema equivalents): + +| Removed | Replacement | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `schemaToJson(schema)` | `standardSchemaToJsonSchema(schema)` | +| `parseSchemaAsync(schema, data)` | `validateStandardSchema(schema, data)` | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | +| `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema` | No replacement — these are now internal Zod introspection helpers | + +### Host header validation moved + +Express-specific middleware (`hostHeaderValidation()`, `localhostHostValidation()`) moved from the server package to `@modelcontextprotocol/express`. The server package now exports framework-agnostic functions instead: `validateHostHeader()`, `localhostAllowedHostnames()`, +`hostHeaderValidationResponse()`. + +**Before (v1):** + +```typescript +import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js'; +app.use(hostHeaderValidation({ allowedHosts: ['example.com'] })); +``` + +**After (v2):** + +```typescript +import { hostHeaderValidation } from '@modelcontextprotocol/express'; +app.use(hostHeaderValidation(['example.com'])); +``` + +Note: the v2 signature takes a plain `string[]` instead of an options object. + +### `setRequestHandler` and `setNotificationHandler` use method strings + +The low-level `setRequestHandler` and `setNotificationHandler` methods on `Client`, `Server`, and `Protocol` now take a method string instead of a Zod schema. + +**Before (v1):** + +```typescript +import { Server, InitializeRequestSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/server/index.js'; + +const server = new Server({ name: 'my-server', version: '1.0.0' }); + +// Request handler with schema +server.setRequestHandler(InitializeRequestSchema, async request => { + return { protocolVersion: '...', capabilities: {}, serverInfo: { name: '...', version: '...' } }; +}); + +// Notification handler with schema +server.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + console.log(notification.params.data); +}); +``` + +**After (v2):** + +```typescript +import { Server } from '@modelcontextprotocol/server'; + +const server = new Server({ name: 'my-server', version: '1.0.0' }); + +// Request handler with method string +server.setRequestHandler('initialize', async request => { + return { protocolVersion: '...', capabilities: {}, serverInfo: { name: '...', version: '...' } }; +}); + +// Notification handler with method string +server.setNotificationHandler('notifications/message', notification => { + console.log(notification.params.data); +}); +``` + +The request and notification parameters remain fully typed via `RequestTypeMap` and `NotificationTypeMap`. You no longer need to import the individual `*RequestSchema` or `*NotificationSchema` constants for handler registration. + +#### Custom (non-spec) methods + +For vendor-prefixed methods (anything not in the MCP spec), use the 3-arg form: pass the method string, a `{ params, result? }` schemas object, and the handler. Any [Standard Schema](https://standardschema.dev) library works (Zod, Valibot, ArkType). + +**Before (v1):** + +```typescript +const AcmeSearch = z.object({ + method: z.literal('acme/search'), + params: z.object({ query: z.string(), limit: z.number().int() }) +}); +server.setRequestHandler(AcmeSearch, async request => { + return { items: [/* ... */] }; +}); +``` + +**After (v2):** + +```typescript +const SearchParams = z.object({ query: z.string(), limit: z.number().int() }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { + return { items: [/* ... */] }; +}); +``` + +The handler receives the parsed `params` directly (not the full request envelope). `_meta` is stripped before validation and is available as `ctx.mcpReq._meta`. Supplying `result` types the handler's return value; omit it to return any `Result`. + +For `setNotificationHandler`, the 3-arg handler is `(params, notification) => void`. The raw notification is the second argument, so `_meta` is recoverable via `notification.params?._meta`. + +#### Sending custom-method requests + +`request()` and `ctx.mcpReq.send()` accept a result schema as the second argument; for custom methods this is required: + +```typescript +const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +result.items; // string[] +``` + +For spec methods the 1-arg form still works and the result type is inferred from the method name. + +Common method string replacements: + +| Schema (v1) | Method string (v2) | +| --------------------------------------- | ---------------------------------------- | +| `InitializeRequestSchema` | `'initialize'` | +| `CallToolRequestSchema` | `'tools/call'` | +| `ListToolsRequestSchema` | `'tools/list'` | +| `ListPromptsRequestSchema` | `'prompts/list'` | +| `GetPromptRequestSchema` | `'prompts/get'` | +| `ListResourcesRequestSchema` | `'resources/list'` | +| `ReadResourceRequestSchema` | `'resources/read'` | +| `CreateMessageRequestSchema` | `'sampling/createMessage'` | +| `ElicitRequestSchema` | `'elicitation/create'` | +| `LoggingMessageNotificationSchema` | `'notifications/message'` | +| `ToolListChangedNotificationSchema` | `'notifications/tools/list_changed'` | +| `ResourceListChangedNotificationSchema` | `'notifications/resources/list_changed'` | +| `PromptListChangedNotificationSchema` | `'notifications/prompts/list_changed'` | + +### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer require a schema parameter for spec methods + +For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas +like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests. + +**`client.request()` — Before (v1):** + +```typescript +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, CallToolResultSchema); +``` + +**After (v2):** + +```typescript +const result = await client.request({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }); +``` + +**`ctx.mcpReq.send()` — Before (v1):** + +```typescript +import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +server.setRequestHandler('tools/call', async (request, ctx) => { + const samplingResult = await ctx.mcpReq.send( + { method: 'sampling/createMessage', params: { messages: [...], maxTokens: 100 } }, + CreateMessageResultSchema + ); + return { content: [{ type: 'text', text: 'done' }] }; +}); +``` + +**After (v2):** + +```typescript +server.setRequestHandler('tools/call', async (request, ctx) => { + const samplingResult = await ctx.mcpReq.send( + { method: 'sampling/createMessage', params: { messages: [...], maxTokens: 100 } } + ); + return { content: [{ type: 'text', text: 'done' }] }; +}); +``` + +**`client.callTool()` — Before (v1):** + +```typescript +import { CompatibilityCallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +const result = await client.callTool({ name: 'my-tool', arguments: {} }, CompatibilityCallToolResultSchema); +``` + +**After (v2):** + +```typescript +const result = await client.callTool({ name: 'my-tool', arguments: {} }); +``` + +The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise`. + +For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method. + +If you were using `CallToolResultSchema` (or any `*Schema` constant) for **runtime validation** (not just in `request()`/`callTool()` calls), use `isSpecType` or `specTypeSchemas`: + +```typescript +// v1: runtime validation with Zod schema +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +if (CallToolResultSchema.safeParse(value).success) { + /* ... */ +} + +// v2: keyed type predicate +import { isSpecType } from '@modelcontextprotocol/client'; +if (isSpecType.CallToolResult(value)) { + /* ... */ +} +const blocks = mixed.filter(isSpecType.ContentBlock); + +// v2: or get the StandardSchemaV1Sync validator object directly +import { specTypeSchemas } from '@modelcontextprotocol/client'; +const result = specTypeSchemas.CallToolResult['~standard'].validate(value); +``` + +`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. + +### Client list methods return empty results for missing capabilities + +`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, and `listTools()` now return empty results when the server didn't advertise the corresponding capability, instead of sending the request. This respects the MCP spec's capability negotiation. + +To restore v1 behavior (throw an error when capabilities are missing), set `enforceStrictCapabilities: true`: + +```typescript +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + enforceStrictCapabilities: true + } +); +``` + +### `InMemoryTransport` moved + +`InMemoryTransport` is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it). It is still intended for in-process client-server connections and testing. + +```typescript +// v1 +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; + +// v2 +import { InMemoryTransport } from '@modelcontextprotocol/server'; +// or +import { InMemoryTransport } from '@modelcontextprotocol/client'; +``` + +### Removed type aliases and deprecated exports + +The following deprecated type aliases have been removed from `@modelcontextprotocol/core`: + +| Removed | Replacement | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `JSONRPCError` | `JSONRPCErrorResponse` | +| `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | +| `isJSONRPCError` | `isJSONRPCErrorResponse` | +| `isJSONRPCResponse` | `isJSONRPCResultResponse` (see note below) | +| `ResourceReferenceSchema` | `ResourceTemplateReferenceSchema` | +| `ResourceReference` | `ResourceTemplateReference` | +| `IsomorphicHeaders` | Use Web Standard `Headers` | +| `AuthInfo` (from `server/auth/types.js`) | `AuthInfo` (now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`) | + +All other types and schemas exported from `@modelcontextprotocol/sdk/types.js` retain their original names — import them from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`. + +> **Note on `isJSONRPCResponse`:** v1's `isJSONRPCResponse` was a deprecated alias that only checked for _result_ responses (it was equivalent to `isJSONRPCResultResponse`). v2 removes the deprecated alias and introduces a **new** `isJSONRPCResponse` with corrected semantics — it +> checks for _any_ response (either result or error). If you are migrating v1 code that used `isJSONRPCResponse`, rename it to `isJSONRPCResultResponse` to preserve the original behavior. Use the new `isJSONRPCResponse` only when you want to match both result and error responses. + +**Before (v1):** + +```typescript +import { JSONRPCError, ResourceReference, isJSONRPCError } from '@modelcontextprotocol/sdk/types.js'; +``` + +**After (v2):** + +```typescript +import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse } from '@modelcontextprotocol/server'; +``` + +### Request handler context types + +The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups: + +| v1 | v2 | +| ---------------------------------------- | ---------------------------------------------------------------------- | +| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) | +| `extra` parameter name | `ctx` parameter name | +| `extra.signal` | `ctx.mcpReq.signal` | +| `extra.requestId` | `ctx.mcpReq.id` | +| `extra._meta` | `ctx.mcpReq._meta` | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | +| `extra.authInfo` | `ctx.http?.authInfo` | +| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) | +| `extra.sessionId` | `ctx.sessionId` | +| `extra.taskStore` | `ctx.task?.store` | +| `extra.taskId` | `ctx.task?.id` | +| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` | + +**Before (v1):** + +```typescript +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + const headers = extra.requestInfo?.headers; + const taskStore = extra.taskStore; + await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); + return { content: [{ type: 'text', text: 'result' }] }; +}); +``` + +**After (v2):** + +```typescript +server.setRequestHandler('tools/call', async (request, ctx) => { + const headers = ctx.http?.req?.headers; // standard Web Request object + const taskStore = ctx.task?.store; + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } }); + return { content: [{ type: 'text', text: 'result' }] }; +}); +``` + +Context fields are organized into 4 groups: + +- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` +- **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE` +- **`task?`** — task lifecycle: `id`, `store`, `requestedTtl` + +`BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. + +`ServerContext` also provides convenience methods for common server→client operations: + +```typescript +server.setRequestHandler('tools/call', async (request, ctx) => { + // Send a log message (respects client's log level filter) + await ctx.mcpReq.log('info', 'Processing tool call', 'my-logger'); + + // Request client to sample an LLM + const samplingResult = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + // Elicit user input via a form + const elicitResult = await ctx.mcpReq.elicitInput({ + message: 'Please provide details', + requestedSchema: { type: 'object', properties: { name: { type: 'string' } } } + }); + + return { content: [{ type: 'text', text: 'done' }] }; +}); +``` + +These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers. + +### Error hierarchy refactoring + +The SDK now distinguishes between three types of errors: + +1. **`ProtocolError`** (renamed from `McpError`): Protocol errors that cross the wire as JSON-RPC error responses +2. **`SdkError`**: Local SDK errors that never cross the wire (timeouts, connection issues, capability checks) +3. **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors + +#### Renamed exports + +| v1 | v2 | +| ---------------------------- | ------------------------------- | +| `McpError` | `ProtocolError` | +| `ErrorCode` | `ProtocolErrorCode` | +| `ErrorCode.RequestTimeout` | `SdkErrorCode.RequestTimeout` | +| `ErrorCode.ConnectionClosed` | `SdkErrorCode.ConnectionClosed` | + +**Before (v1):** + +```typescript +import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +try { + await client.callTool({ name: 'test', arguments: {} }); +} catch (error) { + if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { + console.log('Request timed out'); + } + if (error instanceof McpError && error.code === ErrorCode.InvalidParams) { + console.log('Invalid parameters'); + } +} +``` + +**After (v2):** + +```typescript +import { ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; + +try { + await client.callTool({ name: 'test', arguments: {} }); +} catch (error) { + // Local timeout/connection errors are now SdkError + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + console.log('Request timed out'); + } + // Protocol errors from the server are still ProtocolError + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams) { + console.log('Invalid parameters'); + } +} +``` + +#### New `SdkErrorCode` enum + +The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: + +| Code | Description | +| ------------------------------------------------- | ------------------------------------------- | +| `SdkErrorCode.NotConnected` | Transport is not connected | +| `SdkErrorCode.AlreadyConnected` | Transport is already connected | +| `SdkErrorCode.NotInitialized` | Protocol is not initialized | +| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported | +| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response | +| `SdkErrorCode.ConnectionClosed` | Connection was closed | +| `SdkErrorCode.SendFailed` | Failed to send message | +| `SdkErrorCode.InvalidResult` | Response result failed local schema validation | +| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | +| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | +| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | +| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | +| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | +| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | + +#### `StreamableHTTPError` removed + +The `StreamableHTTPError` class has been removed. HTTP transport errors are now thrown as `SdkHttpError` (a subclass of `SdkError` with typed `.status` and `.statusText` accessors) with specific `SdkErrorCode` values that provide more granular error information: + +**Before (v1):** + +```typescript +import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +try { + await transport.send(message); +} catch (error) { + if (error instanceof StreamableHTTPError) { + console.log('HTTP error:', error.code); // HTTP status code + } +} +``` + +**After (v2):** + +```typescript +import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; + +try { + await transport.send(message); +} catch (error) { + if (error instanceof SdkHttpError) { + console.log('HTTP status:', error.status); // number — no cast needed + console.log('Status text:', error.statusText); // string | undefined + switch (error.code) { + case SdkErrorCode.ClientHttpAuthentication: + console.log('Auth failed — server rejected token after re-auth'); + break; + case SdkErrorCode.ClientHttpForbidden: + console.log('Forbidden after upscoping attempt'); + break; + case SdkErrorCode.ClientHttpFailedToOpenStream: + console.log('Failed to open SSE stream'); + break; + case SdkErrorCode.ClientHttpNotImplemented: + console.log('HTTP request failed'); + break; + } + } +} +``` + +#### Why this change? + +Previously, `ErrorCode.RequestTimeout` (-32001) and `ErrorCode.ConnectionClosed` (-32000) were used for local timeout/connection errors. However, these errors never cross the wire as JSON-RPC responses - they are rejected locally. Using protocol error codes for local errors was +semantically inconsistent. + +The new design: + +- `ProtocolError` with `ProtocolErrorCode`: For errors that are serialized and sent as JSON-RPC error responses +- `SdkError` with `SdkErrorCode`: For local errors that are thrown/rejected locally and never leave the SDK + +### OAuth error refactoring + +The OAuth error classes have been consolidated into a single `OAuthError` class with an `OAuthErrorCode` enum. + +#### Removed classes + +The following individual error classes have been removed in favor of `OAuthError` with the appropriate code: + +| v1 Class | v2 Equivalent | +| ------------------------------ | ----------------------------------------------------------------- | +| `InvalidRequestError` | `new OAuthError(OAuthErrorCode.InvalidRequest, message)` | +| `InvalidClientError` | `new OAuthError(OAuthErrorCode.InvalidClient, message)` | +| `InvalidGrantError` | `new OAuthError(OAuthErrorCode.InvalidGrant, message)` | +| `UnauthorizedClientError` | `new OAuthError(OAuthErrorCode.UnauthorizedClient, message)` | +| `UnsupportedGrantTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedGrantType, message)` | +| `InvalidScopeError` | `new OAuthError(OAuthErrorCode.InvalidScope, message)` | +| `AccessDeniedError` | `new OAuthError(OAuthErrorCode.AccessDenied, message)` | +| `ServerError` | `new OAuthError(OAuthErrorCode.ServerError, message)` | +| `TemporarilyUnavailableError` | `new OAuthError(OAuthErrorCode.TemporarilyUnavailable, message)` | +| `UnsupportedResponseTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedResponseType, message)` | +| `UnsupportedTokenTypeError` | `new OAuthError(OAuthErrorCode.UnsupportedTokenType, message)` | +| `InvalidTokenError` | `new OAuthError(OAuthErrorCode.InvalidToken, message)` | +| `MethodNotAllowedError` | `new OAuthError(OAuthErrorCode.MethodNotAllowed, message)` | +| `TooManyRequestsError` | `new OAuthError(OAuthErrorCode.TooManyRequests, message)` | +| `InvalidClientMetadataError` | `new OAuthError(OAuthErrorCode.InvalidClientMetadata, message)` | +| `InsufficientScopeError` | `new OAuthError(OAuthErrorCode.InsufficientScope, message)` | +| `InvalidTargetError` | `new OAuthError(OAuthErrorCode.InvalidTarget, message)` | +| `CustomOAuthError` | `new OAuthError(customCode, message)` | + +The `OAUTH_ERRORS` constant has also been removed. + +**Before (v1):** + +```typescript +import { InvalidClientError, InvalidGrantError, ServerError } from '@modelcontextprotocol/client'; + +try { + await refreshToken(); +} catch (error) { + if (error instanceof InvalidClientError) { + // Handle invalid client + } else if (error instanceof InvalidGrantError) { + // Handle invalid grant + } else if (error instanceof ServerError) { + // Handle server error + } +} +``` + +**After (v2):** + +```typescript +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; + +try { + await refreshToken(); +} catch (error) { + if (error instanceof OAuthError) { + switch (error.code) { + case OAuthErrorCode.InvalidClient: + // Handle invalid client + break; + case OAuthErrorCode.InvalidGrant: + // Handle invalid grant + break; + case OAuthErrorCode.ServerError: + // Handle server error + break; + } + } +} +``` + +### Experimental: `TaskCreationParams.ttl` no longer accepts `null` + +The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let +the server decide the lifetime. + +This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`. + +**Before (v1):** + +```typescript +// Requesting unlimited lifetime by passing null +const result = await client.callTool({ + name: 'long-task', + arguments: {}, + task: { ttl: null } +}); + +// Handler context had number | null | undefined +server.setRequestHandler('tools/call', async (request, ctx) => { + const ttl: number | null | undefined = ctx.task?.requestedTtl; +}); +``` + +**After (v2):** + +```typescript +// Omit ttl to let the server decide (server may return null for unlimited) +const result = await client.callTool({ + name: 'long-task', + arguments: {}, + task: {} +}); + +// Handler context is now number | undefined +server.setRequestHandler('tools/call', async (request, ctx) => { + const ttl: number | undefined = ctx.task?.requestedTtl; +}); +``` + +> **Note:** These task APIs are marked `@experimental` and may change without notice. + +## Enhancements + +### Automatic JSON Schema validator selection by runtime + +The SDK now automatically selects the appropriate JSON Schema validator based on your runtime environment: + +- **Node.js**: Uses `AjvJsonSchemaValidator` (same as v1 default) +- **Cloudflare Workers**: Uses `CfWorkerJsonSchemaValidator` (previously required manual configuration) + +This means Cloudflare Workers users no longer need to explicitly pass the validator: + +**Before (v1) - Cloudflare Workers required explicit configuration:** + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; + +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { + capabilities: { tools: {} }, + jsonSchemaValidator: new CfWorkerJsonSchemaValidator() // Required in v1 + } +); +``` + +**After (v2) - Works automatically:** + +```typescript +import { McpServer } from '@modelcontextprotocol/server'; + +const server = new McpServer( + { name: 'my-server', version: '1.0.0' }, + { capabilities: { tools: {} } } + // Validator auto-selected based on runtime +); +``` + +You can still explicitly override the validator if needed: + +```typescript +// Runtime-aware default (auto-selects AjvJsonSchemaValidator or CfWorkerJsonSchemaValidator) +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; + +// Specific validators +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; +``` + +## Unchanged APIs + +The following APIs are unchanged between v1 and v2 (only the import paths changed): + +- `Client` constructor and most client methods (`connect`, `listTools`, `listPrompts`, `listResources`, `readResource`, etc.) — note: `callTool()` signature changed (schema parameter removed) +- `McpServer` constructor, `server.connect(transport)`, `server.close()` +- `Server` (low-level) constructor and all methods +- `StreamableHTTPClientTransport`, `SSEClientTransport`, `StdioClientTransport` constructors and options +- `StdioServerTransport` constructor and options +- All Zod schemas and type definitions from `types.ts` (except the aliases listed above) +- Tool, prompt, and resource callback return types + +## Using an LLM to migrate your code + +An LLM-optimized version of this guide is available at [`docs/migration-SKILL.md`](migration-SKILL.md). It contains dense mapping tables designed for tools like Claude Code to mechanically apply all the changes described above. You can paste it into your LLM context or load it as +a skill. + +## Need Help? + +If you encounter issues during migration: + +1. Check the [FAQ](faq.md) for common questions about v2 changes +2. Review the [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) for updated usage patterns +3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues) if you find a bug or need further assistance diff --git a/docs/server-quickstart.md b/docs/server-quickstart.md new file mode 100644 index 0000000..b8d19e7 --- /dev/null +++ b/docs/server-quickstart.md @@ -0,0 +1,476 @@ +--- +title: Server Quickstart +--- + +# Quickstart: Build a weather server + +In this tutorial, we'll build a simple MCP weather server and connect it to a host. + +## What we'll be building + +We'll build a server that exposes two tools: `get-alerts` and `get-forecast`. Then we'll connect the server to an MCP host (in this case, VS Code with GitHub Copilot). + +## Core MCP Concepts + +MCP servers can provide three main types of capabilities: + +1. **[Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)**: File-like data that can be read by clients (like API responses or file contents) +2. **[Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools)**: Functions that can be called by the LLM (with user approval) +3. **[Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts)**: Pre-written templates that help users accomplish specific tasks + +This tutorial will primarily focus on tools. + +Let's get started with building our weather server! [You can find the complete code for what we'll be building here.](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server-quickstart) + +## Prerequisites + +This quickstart assumes you have familiarity with: + +- TypeScript +- LLMs like Claude + +Make sure you have Node.js version 20 or higher installed. You can verify your installation: + +```bash +node --version +npm --version +``` + +> [!TIP] +> The MCP SDK also works with **Bun** and **Deno**. This tutorial uses Node.js, but you can substitute `bun` or `deno` commands where appropriate. For HTTP-based servers on Bun or Deno, use `WebStandardStreamableHTTPServerTransport` instead of the Node.js-specific transport — see the [server guide](./server.md) for details. + +## Set up your environment + +First, let's install Node.js and npm if you haven't already. You can download them from [nodejs.org](https://nodejs.org/). + +Now, let's create and set up our project: + +**macOS/Linux:** + +```bash +# Create a new directory for our project +mkdir weather +cd weather + +# Initialize a new npm project +npm init -y + +# Install dependencies +npm install @modelcontextprotocol/server zod +npm install -D @types/node typescript + +# Create our files +mkdir src +touch src/index.ts +``` + +**Windows:** + +```powershell +# Create a new directory for our project +md weather +cd weather + +# Initialize a new npm project +npm init -y + +# Install dependencies +npm install @modelcontextprotocol/server zod +npm install -D @types/node typescript + +# Create our files +md src +new-item src\index.ts +``` + +Update your `package.json` to add `type: "module"` and a build script: + +```json +{ + "type": "module", + "bin": { + "weather": "./build/index.js" + }, + "scripts": { + "build": "tsc && chmod 755 build/index.js" + }, + "files": ["build"] +} +``` + +Create a `tsconfig.json` in the root of your project: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} +``` + +Now let's dive into building your server. + +## Building your server + +### Importing packages and setting up the instance + +Add these to the top of your `src/index.ts`: + +```ts source="../examples/server-quickstart/src/index.ts#prelude" +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const NWS_API_BASE = 'https://api.weather.gov'; +const USER_AGENT = 'weather-app/1.0'; + +// Create server instance +const server = new McpServer({ + name: 'weather', + version: '1.0.0', +}); +``` + +### Helper functions + +Next, let's add our helper functions for querying and formatting the data from the National Weather Service API: + +```ts source="../examples/server-quickstart/src/index.ts#helpers" +// Helper function for making NWS API requests +async function makeNWSRequest(url: string): Promise { + const headers = { + 'User-Agent': USER_AGENT, + Accept: 'application/geo+json', + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return (await response.json()) as T; + } catch (error) { + console.error('Error making NWS request:', error); + return null; + } +} + +interface AlertFeature { + properties: { + event?: string; + areaDesc?: string; + severity?: string; + status?: string; + headline?: string; + }; +} + +// Format alert data +function formatAlert(feature: AlertFeature): string { + const props = feature.properties; + return [ + `Event: ${props.event || 'Unknown'}`, + `Area: ${props.areaDesc || 'Unknown'}`, + `Severity: ${props.severity || 'Unknown'}`, + `Status: ${props.status || 'Unknown'}`, + `Headline: ${props.headline || 'No headline'}`, + '---', + ].join('\n'); +} + +interface ForecastPeriod { + name?: string; + temperature?: number; + temperatureUnit?: string; + windSpeed?: string; + windDirection?: string; + shortForecast?: string; +} + +interface AlertsResponse { + features: AlertFeature[]; +} + +interface PointsResponse { + properties: { + forecast?: string; + }; +} + +interface ForecastResponse { + properties: { + periods: ForecastPeriod[]; + }; +} +``` + +### Registering tools + +Each tool is registered with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | server.registerTool()}, which takes the tool name, a configuration object (with description and input schema), and a callback that implements the tool logic. Let's register our two weather tools: + +```ts source="../examples/server-quickstart/src/index.ts#registerTools" +// Register weather tools +server.registerTool( + 'get-alerts', + { + title: 'Get Weather Alerts', + description: 'Get weather alerts for a state', + inputSchema: z.object({ + state: z.string().length(2) + .describe('Two-letter state code (e.g. CA, NY)'), + }), + }, + async ({ state }) => { + const stateCode = state.toUpperCase(); + const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; + const alertsData = await makeNWSRequest(alertsUrl); + + if (!alertsData) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to retrieve alerts data', + }], + }; + } + + const features = alertsData.features || []; + + if (features.length === 0) { + return { + content: [{ + type: 'text' as const, + text: `No active alerts for ${stateCode}`, + }], + }; + } + + const formattedAlerts = features.map(formatAlert); + + return { + content: [{ + type: 'text' as const, + text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, + }], + }; + }, +); + +server.registerTool( + 'get-forecast', + { + title: 'Get Weather Forecast', + description: 'Get weather forecast for a location', + inputSchema: z.object({ + latitude: z.number().min(-90).max(90) + .describe('Latitude of the location'), + longitude: z.number().min(-180).max(180) + .describe('Longitude of the location'), + }), + }, + async ({ latitude, longitude }) => { + // Get grid point data + const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; + const pointsData = await makeNWSRequest(pointsUrl); + + if (!pointsData) { + return { + content: [{ + type: 'text' as const, + text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, + }], + }; + } + + const forecastUrl = pointsData.properties?.forecast; + if (!forecastUrl) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to get forecast URL from grid point data', + }], + }; + } + + // Get forecast data + const forecastData = await makeNWSRequest(forecastUrl); + if (!forecastData) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to retrieve forecast data', + }], + }; + } + + const periods = forecastData.properties?.periods || []; + if (periods.length === 0) { + return { + content: [{ + type: 'text' as const, + text: 'No forecast periods available', + }], + }; + } + + // Format forecast periods + const formattedForecast = periods.map((period: ForecastPeriod) => + [ + `${period.name || 'Unknown'}:`, + `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, + `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, + `${period.shortForecast || 'No forecast available'}`, + '---', + ].join('\n'), + ); + + return { + content: [{ + type: 'text' as const, + text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, + }], + }; + }, +); +``` + +### Running the server + +Finally, implement the main function to run the server: + +```ts source="../examples/server-quickstart/src/index.ts#main" +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Weather MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error in main():', error); + process.exit(1); +}); +``` + +> [!IMPORTANT] +> Always use `console.error()` instead of `console.log()` in stdio-based MCP servers. Standard output is reserved for JSON-RPC protocol messages, and writing to it with `console.log()` will corrupt the communication channel. + +Make sure to run `npm run build` to build your server! This is a very important step in getting your server to connect. + +Let's now test your server from an existing MCP host. + +## Testing your server in VS Code + +[VS Code](https://code.visualstudio.com/) with [GitHub Copilot](https://github.com/features/copilot) can discover and invoke MCP tools via agent mode. [Copilot Free](https://github.com/features/copilot/plans) is sufficient to follow along. + +> [!NOTE] +> Servers can connect to any client. We've chosen VS Code here for simplicity, but we also have a guide on [building your own client](./client-quickstart.md) as well as a [list of other clients here](https://modelcontextprotocol.io/clients). + +### Prerequisites + +1. Install [VS Code](https://code.visualstudio.com/) (version 1.99 or later). +2. Install the **GitHub Copilot** extension from the VS Code Extensions marketplace. +3. Sign in to your GitHub account when prompted. + +### Configure the MCP server + +Create a `.vscode/mcp.json` file in your `weather` project root: + +```json +{ + "servers": { + "weather": { + "type": "stdio", + "command": "node", + "args": ["./build/index.js"] + } + } +} +``` + +VS Code may prompt you to trust the MCP server when it detects this file. If prompted, confirm to start the server. + +To verify, run **MCP: List Servers** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`). The `weather` server should show a running status. + +### Use the tools + +1. Open **Copilot Chat** (`Ctrl+Alt+I` / `Ctrl+Cmd+I`). +2. Select **Agent** mode from the mode selector at the top of the chat panel. +3. Click the **Tools** button to confirm `get-alerts` and `get-forecast` appear. +4. Try these prompts: + - "What's the weather in Sacramento?" + - "What are the active weather alerts in Texas?" + +> [!NOTE] +> Since this is the US National Weather Service, the queries will only work for US locations. + +## What's happening under the hood + +When you ask a question: + +1. The client sends your question to the LLM +2. The LLM analyzes the available tools and decides which one(s) to use +3. The client executes the chosen tool(s) through the MCP server +4. The results are sent back to the LLM +5. The LLM formulates a natural language response +6. The response is displayed to you + +## Troubleshooting + +
+VS Code integration issues + +**Server not appearing or fails to start** + +1. Verify you have VS Code 1.99 or later (`Help > About`) and that GitHub Copilot is installed. +2. Verify the server builds without errors: run `npm run build` in the `weather` directory. +3. Test it manually: run `node build/index.js` — the process should start and wait for input. Press `Ctrl+C` to exit. +4. Check the server logs: in **MCP: List Servers**, select the server and choose **Show Output**. +5. If the `node` command is not found, use the full path to the Node binary. + +**Tools don't appear in Copilot Chat** + +1. Confirm you're in **Agent** mode (not Ask or Edit mode). +2. Run **MCP: Reset Cached Tools** from the Command Palette, then recheck the **Tools** list. + +
+ +
+Weather API issues + +**Error: Failed to retrieve grid point data** + +This usually means either: + +1. The coordinates are outside the US +2. The NWS API is having issues +3. You're being rate limited + +Fix: + +- Verify you're using US coordinates +- Add a small delay between requests +- Check the NWS API status page + +**Error: No active alerts for [STATE]** + +This isn't an error - it just means there are no current weather alerts for that state. Try a different state or check during severe weather. + +
+ +## Next steps + +Now that your server is running locally, here are some ways to go further: + +- [**Server guide**](./server.md) — Add resources, prompts, logging, error handling, and remote transports to your server. +- [**Example servers**](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Browse runnable examples covering OAuth, streaming, sessions, and more. +- [**FAQ**](./faq.md) — Troubleshoot common errors (Zod version conflicts, transport issues, etc.). diff --git a/docs/server.md b/docs/server.md new file mode 100644 index 0000000..3b173af --- /dev/null +++ b/docs/server.md @@ -0,0 +1,592 @@ +--- +title: Server Guide +--- + +# Building MCP servers + +This guide covers the TypeScript SDK APIs for building MCP servers. For protocol-level concepts — what tools, resources, and prompts are and when to use each — see the [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture). + +Building a server takes three steps: + +1. Create an {@linkcode @modelcontextprotocol/server!server/mcp.McpServer | McpServer} and register your [tools](#tools), [resources](#resources), and [prompts](#prompts). +2. Create a transport — [Streamable HTTP](#streamable-http) for remote servers or [stdio](#stdio) for local integrations. +3. Connect them with `server.connect(transport)`. + +## Imports + +The examples below use these imports. Adjust based on which features and transport you need: + +```ts source="../examples/server/src/serverGuide.examples.ts#imports" +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; +``` + +## Transports + +MCP supports two transport mechanisms (see [Transport layer](https://modelcontextprotocol.io/docs/learn/architecture#transport-layer) in the MCP overview). Choose based on deployment model: + +- **Streamable HTTP** — for remote servers accessible over the network. +- **stdio** — for local servers spawned as child processes (Claude Desktop, CLI tools). + +### Streamable HTTP + +Create a {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} and connect it to your server: + +```ts source="../examples/server/src/serverGuide.examples.ts#streamableHttp_stateful" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() +}); + +await server.connect(transport); +``` + +**Options:** Set `sessionIdGenerator` to a function (shown above) for stateful sessions. Set it to `undefined` for stateless mode (simpler, but does not support resumability). Set `enableJsonResponse: true` to return plain JSON instead of SSE streams. + +For a complete server with sessions, logging, and CORS mounted on Express, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). + +### stdio + +For local, process-spawned integrations, use {@linkcode @modelcontextprotocol/server!server/stdio.StdioServerTransport | StdioServerTransport}: + +```ts source="../examples/server/src/serverGuide.examples.ts#stdio_basic" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +## Server instructions + +Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions. + +```ts source="../examples/server/src/serverGuide.examples.ts#instructions_basic" +const server = new McpServer( + { name: 'db-server', version: '1.0.0' }, + { + instructions: + 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' + } +); +``` + +## Tools + +Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). + +Register a tool with {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#registerTool | registerTool}. Provide an `inputSchema` (Zod) to validate arguments, and optionally an `outputSchema` for structured return values: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_basic" +server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: z.object({ + weightKg: z.number(), + heightM: z.number() + }), + outputSchema: z.object({ bmi: z.number() }) + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); +``` + +> [!NOTE] +> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`: +> +> ```ts +> type BmiResult = { bmi: number }; // assignable +> interface BmiResult { bmi: number } // type error +> ``` +> +> Alternatively, spread the value: `structuredContent: { ...result }`. + +### `ResourceLink` outputs + +Tools can return `resource_link` content items to reference large resources without embedding them, letting clients fetch only what they need: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_resourceLink" +server.registerTool( + 'list-files', + { + title: 'List Files', + description: 'Returns files as resource links without embedding content' + }, + async (): Promise => { + const links: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'file:///projects/readme.md', + name: 'README', + mimeType: 'text/markdown' + }, + { + type: 'resource_link', + uri: 'file:///projects/config.json', + name: 'Config', + mimeType: 'application/json' + } + ]; + return { content: links }; + } +); +``` + +### Tool annotations + +Tools can include annotations that hint at their behavior — whether a tool is read-only, destructive, or idempotent. Annotations help clients present tools appropriately without changing execution semantics: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_annotations" +server.registerTool( + 'delete-file', + { + description: 'Delete a file from the project', + inputSchema: z.object({ path: z.string() }), + annotations: { + title: 'Delete File', + destructiveHint: true, + idempotentHint: true + } + }, + async ({ path }): Promise => { + // ... perform deletion ... + return { content: [{ type: 'text', text: `Deleted ${path}` }] }; + } +); +``` + +### Error handling + +Return `isError: true` to report tool-level errors. The LLM sees these and can self-correct, unlike protocol-level errors which are hidden from it: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_errorHandling" +server.registerTool( + 'fetch-data', + { + description: 'Fetch data from a URL', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }): Promise => { + try { + const res = await fetch(url); + if (!res.ok) { + return { + content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], + isError: true + }; + } + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } catch (error) { + return { + content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : String(error)}` }], + isError: true + }; + } + } +); +``` + +If a handler throws instead of returning `isError`, the SDK catches the exception and converts it to `{ isError: true }` automatically — so an explicit try/catch is optional but gives you control over the error message. When `isError` is true, output schema validation is skipped. + +## Resources + +Resources expose read-only data — files, database schemas, configuration — that the host application can retrieve and attach as context for the model (see [Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources) in the MCP overview). Unlike [tools](#tools), which the LLM invokes on its own, resources are application-controlled: the host decides which resources to fetch and how to present them. + +A static resource at a fixed URI: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_static" +server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) +); +``` + +Dynamic resources use {@linkcode @modelcontextprotocol/server!server/mcp.ResourceTemplate | ResourceTemplate} with URI patterns. The `list` callback lets clients discover available instances: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerResource_template" +server.registerResource( + 'user-profile', + new ResourceTemplate('user://{userId}/profile', { + list: async () => ({ + resources: [ + { uri: 'user://123/profile', name: 'Alice' }, + { uri: 'user://456/profile', name: 'Bob' } + ] + }) + }), + { + title: 'User Profile', + description: 'User profile data', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify({ userId, name: 'Example User' }) + } + ] + }) +); +``` + +## Prompts + +Prompts are reusable templates that help structure interactions with models (see [Prompts](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) in the MCP overview). Use a prompt when you want to offer a canned interaction pattern that users invoke explicitly; use a [tool](#tools) when the LLM should decide when to call it. + +```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_basic" +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string() + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Please review this code:\n\n${code}` + } + } + ] + }) +); +``` + +## Completions + +Both prompts and resources can support argument completions. Wrap a field in the `argsSchema` with {@linkcode @modelcontextprotocol/server!server/completable.completable | completable()} to provide autocompletion suggestions: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerPrompt_completion" +server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code for best practices.` + } + } + ] + }) +); +``` + +## Logging + +Logging lets your server send structured diagnostics — debug traces, progress updates, warnings — to the connected client as notifications (see [Logging](https://modelcontextprotocol.io/specification/latest/server/utilities/logging) in the MCP specification). + +Declare the `logging` capability, then call `ctx.mcpReq.log(level, data)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside any handler: + +```ts source="../examples/server/src/serverGuide.examples.ts#logging_capability" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); +``` + +Then log from any handler: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_logging" +server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } +); +``` + +## Progress + +Progress notifications let a tool report incremental status updates during long-running operations (see [Progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) in the MCP specification). + +If the client includes a `progressToken` in the request `_meta`, send `notifications/progress` via `ctx.mcpReq.notify()` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}): + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_progress" +server.registerTool( + 'process-files', + { + description: 'Process files with progress updates', + inputSchema: z.object({ files: z.array(z.string()) }) + }, + async ({ files }, ctx): Promise => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + for (let i = 0; i < files.length; i++) { + // ... process files[i] ... + + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: i + 1, + total: files.length, + message: `Processed ${files[i]}` + } + }); + } + } + + return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; + } +); +``` + +`progress` must increase on each call. `total` and `message` are optional. If the client does not provide a `progressToken`, skip the notification. + +## Server-initiated requests + +MCP is bidirectional — servers can send requests *to* the client during tool execution, as long as the client declares matching capabilities (see [Architecture](https://modelcontextprotocol.io/docs/learn/architecture) in the MCP overview). + +### Sampling + +Sampling lets a tool handler request an LLM completion from the connected client — the handler describes a prompt and the client returns the model's response (see [Sampling](https://modelcontextprotocol.io/docs/learn/client-concepts#sampling) in the MCP overview). Use sampling when a tool needs the model to generate or transform text mid-execution. + +Call `ctx.mcpReq.requestSampling(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_sampling" +server.registerTool( + 'summarize', + { + description: 'Summarize text using the client LLM', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }, ctx): Promise => { + const response = await ctx.mcpReq.requestSampling({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please summarize:\n\n${text}` + } + } + ], + maxTokens: 500 + }); + return { + content: [ + { + type: 'text', + text: `Model (${response.model}): ${JSON.stringify(response.content)}` + } + ] + }; + } +); +``` + +For a full runnable example, see [`toolWithSampleServer.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/toolWithSampleServer.ts). + +### Elicitation + +Elicitation lets a tool handler request direct input from the user — form fields, confirmations, or a redirect to a URL (see [Elicitation](https://modelcontextprotocol.io/docs/learn/client-concepts#elicitation) in the MCP overview). It supports two modes: + +- **Form** (`mode: 'form'`) — collects non-sensitive data via a schema-driven form. +- **URL** (`mode: 'url'`) — opens a browser URL for sensitive data or secure flows (API keys, payments, OAuth). + +> [!IMPORTANT] +> Sensitive information must not be collected via form elicitation; always use URL elicitation or out-of-band flows for secrets. + +Call `ctx.mcpReq.elicitInput(params)` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) inside a tool handler: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_elicitation" +server.registerTool( + 'collect-feedback', + { + description: 'Collect user feedback via a form', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please share your feedback:', + requestedSchema: { + type: 'object', + properties: { + rating: { + type: 'number', + title: 'Rating (1\u20135)', + minimum: 1, + maximum: 5 + }, + comment: { type: 'string', title: 'Comment' } + }, + required: ['rating'] + } + }); + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thanks! ${JSON.stringify(result.content)}` + } + ] + }; + } + return { content: [{ type: 'text', text: 'Feedback declined.' }] }; + } +); +``` + +For runnable examples, see [`elicitationFormExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationFormExample.ts) (form) and [`elicitationUrlExample.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/elicitationUrlExample.ts) (URL). + +### Roots + +Roots let a tool handler discover the client's workspace directories — for example, to scope a file search or identify project boundaries (see [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) in the MCP overview). Call {@linkcode @modelcontextprotocol/server!server/server.Server#listRoots | server.server.listRoots()} (requires the client to declare the `roots` capability): + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_roots" +server.registerTool( + 'list-workspace-files', + { + description: 'List files across all workspace roots', + inputSchema: z.object({}) + }, + async (_args, _ctx): Promise => { + const { roots } = await server.server.listRoots(); + const summary = roots.map(r => `${r.name ?? r.uri}: ${r.uri}`).join('\n'); + return { content: [{ type: 'text', text: summary }] }; + } +); +``` + +## Tasks (experimental) + +> [!WARNING] +> The tasks API is experimental and may change without notice. + +Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks: + +- Provide a {@linkcode @modelcontextprotocol/server!index.TaskStore | TaskStore} implementation that persists task metadata and results (see {@linkcode @modelcontextprotocol/server!index.InMemoryTaskStore | InMemoryTaskStore} for reference). +- Enable the `tasks` capability when constructing the server. +- Register tools with {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | server.experimental.tasks.registerToolTask(...)}. + +For a full runnable example, see [`simpleTaskInteractive.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleTaskInteractive.ts). + +## Shutdown + +For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: + +```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_statefulHttp" +// Capture the http.Server so it can be closed on shutdown +const httpServer = app.listen(3000); + +process.on('SIGINT', async () => { + httpServer.close(); + + for (const [sessionId, transport] of transports) { + await transport.close(); + transports.delete(sessionId); + } + + process.exit(0); +}); +``` + +Calling {@linkcode @modelcontextprotocol/server!index.Transport#close | transport.close()} closes SSE streams and rejects any pending outbound requests. In-flight tool handlers are not automatically drained — they are terminated when the process exits. + +For stdio servers, {@linkcode @modelcontextprotocol/server!server/mcp.McpServer#close | server.close()} is sufficient: + +```ts source="../examples/server/src/serverGuide.examples.ts#shutdown_stdio" +process.on('SIGINT', async () => { + await server.close(); + process.exit(0); +}); +``` + +For a complete multi-session server with shutdown handling, see [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts). + +## Deployment + +### DNS rebinding protection + +Under normal circumstances, cross-origin browser restrictions limit what a malicious website can do to your localhost server. [DNS rebinding attacks](https://en.wikipedia.org/wiki/DNS_rebinding) get around those restrictions entirely by making the requests appear as same-origin, since the attacking domain resolves to localhost. Validating the host header on the server side protects against this scenario. **All localhost MCP servers should use DNS rebinding protection.** + +The recommended approach is to use {@linkcode @modelcontextprotocol/express!express.createMcpExpressApp | createMcpExpressApp()} (from `@modelcontextprotocol/express`) or {@linkcode @modelcontextprotocol/hono!hono.createMcpHonoApp | createMcpHonoApp()} (from `@modelcontextprotocol/hono`), which enable Host header validation by default: + +```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_basic" +// Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) +const app = createMcpExpressApp(); + +// DNS rebinding protection also auto-enabled for localhost +const appLocal = createMcpExpressApp({ host: 'localhost' }); + +// No automatic protection when binding to all interfaces +const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); +``` + +When binding to `0.0.0.0` / `::`, provide an allow-list of hosts: + +```ts source="../examples/server/src/serverGuide.examples.ts#dnsRebinding_allowedHosts" +const app = createMcpExpressApp({ + host: '0.0.0.0', + allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] +}); +``` + +`createMcpHonoApp()` from `@modelcontextprotocol/hono` provides the same protection for Hono-based servers and Web Standard runtimes (Cloudflare Workers, Deno, Bun). + +If you use `NodeStreamableHTTPServerTransport` directly with your own HTTP framework, you must implement Host header validation yourself. See the [`hostHeaderValidation`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/middleware/express/src/express.ts) middleware source for reference. + +## See also + +- [`examples/server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/server) — Full runnable server examples +- [Client guide](./client.md) — Building MCP clients with this SDK +- [MCP overview](https://modelcontextprotocol.io/docs/learn/architecture) — Protocol-level concepts: participants, layers, primitives +- [Migration guide](./migration.md) — Upgrading from previous SDK versions +- [FAQ](./faq.md) — Frequently asked questions and troubleshooting + +### Additional examples + +| Feature | Description | Example | +|---------|-------------|---------| +| Web Standard transport | Deploy on Cloudflare Workers, Deno, or Bun | [`honoWebStandardStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/honoWebStandardStreamableHttp.ts) | +| Session management | Per-session transport routing, initialization, and cleanup | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Resumability | Replay missed SSE events via an event store | [`inMemoryEventStore.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/inMemoryEventStore.ts) | +| CORS | Expose MCP headers for browser clients | [`simpleStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStreamableHttp.ts) | +| Multi-node deployment | Stateless, persistent-storage, and distributed routing patterns | [`examples/server/README.md`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/README.md#multi-node-deployment-patterns) | diff --git a/docs/v2-banner.js b/docs/v2-banner.js new file mode 100644 index 0000000..ce0637c --- /dev/null +++ b/docs/v2-banner.js @@ -0,0 +1,9 @@ +document.addEventListener("DOMContentLoaded", function () { + var banner = document.createElement("div"); + banner.innerHTML = + "This documents a pre-release version of the SDK. Expect breaking changes. For the stable SDK, see the V1 docs."; + banner.style.cssText = + "background:#fff3cd;color:#856404;border-bottom:1px solid #ffc107;padding:8px 16px;text-align:center;font-size:14px;"; + banner.querySelector("a").style.cssText = "color:#856404;text-decoration:underline;"; + document.body.insertBefore(banner, document.body.firstChild); +}); diff --git a/examples/client-quickstart/.gitignore b/examples/client-quickstart/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/examples/client-quickstart/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/examples/client-quickstart/package.json b/examples/client-quickstart/package.json new file mode 100644 index 0000000..98919df --- /dev/null +++ b/examples/client-quickstart/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modelcontextprotocol/examples-client-quickstart", + "private": true, + "version": "2.0.0-alpha.0", + "type": "module", + "bin": { + "mcp-client-cli": "./build/index.js" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.74.0", + "@modelcontextprotocol/client": "workspace:^" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "catalog:devTools" + } +} diff --git a/examples/client-quickstart/src/index.ts b/examples/client-quickstart/src/index.ts new file mode 100644 index 0000000..f677834 --- /dev/null +++ b/examples/client-quickstart/src/index.ts @@ -0,0 +1,188 @@ +//#region prelude +import Anthropic from '@anthropic-ai/sdk'; +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import readline from 'readline/promises'; + +const ANTHROPIC_MODEL = 'claude-sonnet-4-5'; + +class MCPClient { + private mcp: Client; + private _anthropic: Anthropic | null = null; + private transport: StdioClientTransport | null = null; + private tools: Anthropic.Tool[] = []; + + constructor() { + // Initialize MCP client + this.mcp = new Client({ name: 'mcp-client-cli', version: '1.0.0' }); + } + + private get anthropic(): Anthropic { + // Lazy-initialize Anthropic client when needed + return this._anthropic ??= new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); + } +//#endregion prelude + +//#region connectToServer + async connectToServer(serverScriptPath: string) { + try { + // Determine script type and appropriate command + const isJs = serverScriptPath.endsWith('.js'); + const isPy = serverScriptPath.endsWith('.py'); + if (!isJs && !isPy) { + throw new Error('Server script must be a .js or .py file'); + } + const command = isPy + ? (process.platform === 'win32' ? 'python' : 'python3') + : process.execPath; + + // Initialize transport and connect to server + this.transport = new StdioClientTransport({ command, args: [serverScriptPath] }); + await this.mcp.connect(this.transport); + + // List available tools + const toolsResult = await this.mcp.listTools(); + this.tools = toolsResult.tools.map((tool) => ({ + name: tool.name, + description: tool.description ?? '', + input_schema: tool.inputSchema as Anthropic.Tool.InputSchema, + })); + console.log('Connected to server with tools:', this.tools.map(({ name }) => name)); + } catch (e) { + console.log('Failed to connect to MCP server: ', e); + throw e; + } + } +//#endregion connectToServer + +//#region processQuery + async processQuery(query: string) { + const messages: Anthropic.MessageParam[] = [ + { + role: 'user', + content: query, + }, + ]; + + // Initial Claude API call + const response = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + tools: this.tools, + }); + + // Process response and handle tool calls + const finalText = []; + + for (const content of response.content) { + if (content.type === 'text') { + finalText.push(content.text); + } else if (content.type === 'tool_use') { + // Execute tool call + const toolName = content.name; + const toolArgs = content.input as Record | undefined; + const result = await this.mcp.callTool({ + name: toolName, + arguments: toolArgs, + }); + + finalText.push(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`); + + // Extract text from tool result content blocks + const toolResultText = result.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n'); + + // Continue conversation with tool results + messages.push({ + role: 'assistant', + content: response.content, + }); + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: content.id, + content: toolResultText, + }], + }); + + // Get next response from Claude + const followUp = await this.anthropic.messages.create({ + model: ANTHROPIC_MODEL, + max_tokens: 1000, + messages, + }); + + finalText.push(followUp.content[0].type === 'text' ? followUp.content[0].text : ''); + } + } + + return finalText.join('\n'); + } +//#endregion processQuery + +//#region chatLoop + async chatLoop() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log('\nMCP Client Started!'); + console.log('Type your queries or "quit" to exit.'); + + while (true) { + const message = await rl.question('\nQuery: '); + if (message.toLowerCase() === 'quit') { + break; + } + const response = await this.processQuery(message); + console.log('\n' + response); + } + } finally { + rl.close(); + } + } + + async cleanup() { + await this.mcp.close(); + } +} +//#endregion chatLoop + +//#region main +async function main() { + if (process.argv.length < 3) { + console.log('Usage: node build/index.js '); + return; + } + const mcpClient = new MCPClient(); + try { + await mcpClient.connectToServer(process.argv[2]); + + // Check if we have a valid API key to continue + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + console.log( + '\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:' + + '\n export ANTHROPIC_API_KEY=your-api-key-here' + ); + return; + } + + await mcpClient.chatLoop(); + } catch (e) { + console.error('Error:', e); + process.exit(1); + } finally { + await mcpClient.cleanup(); + process.exit(0); + } +} + +main(); +//#endregion main diff --git a/examples/client-quickstart/tsconfig.json b/examples/client-quickstart/tsconfig.json new file mode 100644 index 0000000..e7b40b5 --- /dev/null +++ b/examples/client-quickstart/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 0000000..12a2b0d --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,52 @@ +# MCP TypeScript SDK Examples (Client) + +This directory contains runnable MCP **client** examples built with `@modelcontextprotocol/client`. + +For server examples, see [`../server/README.md`](../server/README.md). For guided docs, see [`../../docs/client.md`](../../docs/client.md). + +## Running examples + +From the repo root: + +```bash +pnpm install +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleStreamableHttp.ts +``` + +Or, from within this package: + +```bash +cd examples/client +pnpm tsx src/simpleStreamableHttp.ts +``` + +Most clients expect a server to be running. Start one from [`../server/README.md`](../server/README.md) (for example `src/simpleStreamableHttp.ts` in `examples/server`). + +## Example index + +| Scenario | Description | File | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) | +| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) | +| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) | +| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) | +| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) | +| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) | +| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | +| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) | + +## URL elicitation example (server + client) + +Run the server first: + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts +``` + +Then run the client: + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts +``` diff --git a/examples/client/eslint.config.mjs b/examples/client/eslint.config.mjs new file mode 100644 index 0000000..83b7987 --- /dev/null +++ b/examples/client/eslint.config.mjs @@ -0,0 +1,14 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Allow console statements in examples only + 'no-console': 'off' + } + } +]; diff --git a/examples/client/package.json b/examples/client/package.json new file mode 100644 index 0000000..57b329f --- /dev/null +++ b/examples/client/package.json @@ -0,0 +1,47 @@ +{ + "name": "@modelcontextprotocol/examples-client", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build:esm && pnpm run build:cjs", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "start": "pnpm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@modelcontextprotocol/client": "workspace:^", + "ajv": "catalog:runtimeShared", + "open": "^11.0.0", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "tsdown": "catalog:devTools" + } +} diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts new file mode 100644 index 0000000..9704ed8 --- /dev/null +++ b/examples/client/src/clientGuide.examples.ts @@ -0,0 +1,575 @@ +/** + * Type-checked examples for docs/client.md. + * + * Regions are synced into markdown code fences via `pnpm sync:snippets`. + * Each function wraps a single region. The function name matches the region name. + * + * @module + */ + +//#region imports +import type { AuthProvider, Prompt, Resource, Tool } from '@modelcontextprotocol/client'; +import { + applyMiddlewares, + Client, + ClientCredentialsProvider, + createMiddleware, + CrossAppAccessProvider, + discoverAndRequestJwtAuthGrant, + PrivateKeyJwtProvider, + ProtocolError, + SdkError, + SdkErrorCode, + SSEClientTransport, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +//#endregion imports + +// --------------------------------------------------------------------------- +// Connecting to a server +// --------------------------------------------------------------------------- + +/** Example: Streamable HTTP transport. */ +async function connect_streamableHttp() { + //#region connect_streamableHttp + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); + + await client.connect(transport); + //#endregion connect_streamableHttp +} + +/** Example: stdio transport for local process-spawned servers. */ +async function connect_stdio() { + //#region connect_stdio + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['server.js'] + }); + + await client.connect(transport); + //#endregion connect_stdio +} + +/** Example: Try Streamable HTTP, fall back to legacy SSE. */ +async function connect_sseFallback(url: string) { + //#region connect_sseFallback + const baseUrl = new URL(url); + + try { + // Try modern Streamable HTTP transport first + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } catch { + // Fall back to legacy SSE transport + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new SSEClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } + //#endregion connect_sseFallback +} + +// --------------------------------------------------------------------------- +// Disconnecting +// --------------------------------------------------------------------------- + +/** Example: Graceful disconnect for Streamable HTTP. */ +async function disconnect_streamableHttp(client: Client, transport: StreamableHTTPClientTransport) { + //#region disconnect_streamableHttp + await transport.terminateSession(); // notify the server (recommended) + await client.close(); + //#endregion disconnect_streamableHttp +} + +// --------------------------------------------------------------------------- +// Server instructions +// --------------------------------------------------------------------------- + +/** Example: Access server instructions after connecting. */ +async function serverInstructions_basic(client: Client) { + //#region serverInstructions_basic + const instructions = client.getInstructions(); + + const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n'); + + console.log(systemPrompt); + //#endregion serverInstructions_basic +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +/** Example: Minimal AuthProvider for bearer auth with externally-managed tokens. */ +async function auth_tokenProvider(getStoredToken: () => Promise) { + //#region auth_tokenProvider + const authProvider: AuthProvider = { token: async () => getStoredToken() }; + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + //#endregion auth_tokenProvider + return transport; +} + +/** Example: Client credentials auth for service-to-service communication. */ +async function auth_clientCredentials() { + //#region auth_clientCredentials + const authProvider = new ClientCredentialsProvider({ + clientId: 'my-service', + clientSecret: 'my-secret' + }); + + const client = new Client({ name: 'my-client', version: '1.0.0' }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + + await client.connect(transport); + //#endregion auth_clientCredentials +} + +/** Example: Private key JWT auth. */ +async function auth_privateKeyJwt(pemEncodedKey: string) { + //#region auth_privateKeyJwt + const authProvider = new PrivateKeyJwtProvider({ + clientId: 'my-service', + privateKey: pemEncodedKey, + algorithm: 'RS256' + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + //#endregion auth_privateKeyJwt + return transport; +} + +/** Example: Cross-App Access (SEP-990 Enterprise Managed Authorization). */ +async function auth_crossAppAccess(getIdToken: () => Promise) { + //#region auth_crossAppAccess + const authProvider = new CrossAppAccessProvider({ + assertion: async ctx => { + // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: ctx.authorizationServerUrl, + resource: ctx.resourceUrl, + idToken: await getIdToken(), + clientId: 'my-idp-client', + clientSecret: 'my-idp-secret', + scope: ctx.scope, + fetchFn: ctx.fetchFn + }); + return result.jwtAuthGrant; + }, + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret' + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider }); + //#endregion auth_crossAppAccess + return transport; +} + +// --------------------------------------------------------------------------- +// Using server features +// --------------------------------------------------------------------------- + +/** Example: List and call tools. */ +async function callTool_basic(client: Client) { + //#region callTool_basic + const allTools: Tool[] = []; + let toolCursor: string | undefined; + do { + const { tools, nextCursor } = await client.listTools({ cursor: toolCursor }); + allTools.push(...tools); + toolCursor = nextCursor; + } while (toolCursor); + console.log( + 'Available tools:', + allTools.map(t => t.name) + ); + + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } + }); + console.log(result.content); + //#endregion callTool_basic +} + +/** Example: Structured tool output. */ +async function callTool_structuredOutput(client: Client) { + //#region callTool_structuredOutput + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } + }); + + // Machine-readable output for the client application + if (result.structuredContent) { + console.log(result.structuredContent); // e.g. { bmi: 22.86 } + } + //#endregion callTool_structuredOutput +} + +/** Example: Track progress of a long-running tool call. */ +async function callTool_progress(client: Client) { + //#region callTool_progress + const result = await client.callTool( + { name: 'long-operation', arguments: {} }, + { + onprogress: ({ progress, total }: { progress: number; total?: number }) => { + console.log(`Progress: ${progress}/${total ?? '?'}`); + }, + resetTimeoutOnProgress: true, + maxTotalTimeout: 600_000 + } + ); + console.log(result.content); + //#endregion callTool_progress +} + +/** Example: List and read resources. */ +async function readResource_basic(client: Client) { + //#region readResource_basic + const allResources: Resource[] = []; + let resourceCursor: string | undefined; + do { + const { resources, nextCursor } = await client.listResources({ cursor: resourceCursor }); + allResources.push(...resources); + resourceCursor = nextCursor; + } while (resourceCursor); + console.log( + 'Available resources:', + allResources.map(r => r.name) + ); + + const { contents } = await client.readResource({ uri: 'config://app' }); + for (const item of contents) { + console.log(item); + } + //#endregion readResource_basic +} + +/** Example: Subscribe to resource changes. */ +async function subscribeResource_basic(client: Client) { + //#region subscribeResource_basic + await client.subscribeResource({ uri: 'config://app' }); + + client.setNotificationHandler('notifications/resources/updated', async notification => { + if (notification.params.uri === 'config://app') { + const { contents } = await client.readResource({ uri: 'config://app' }); + console.log('Config updated:', contents); + } + }); + + // Later: stop receiving updates + await client.unsubscribeResource({ uri: 'config://app' }); + //#endregion subscribeResource_basic +} + +/** Example: List and get prompts. */ +async function getPrompt_basic(client: Client) { + //#region getPrompt_basic + const allPrompts: Prompt[] = []; + let promptCursor: string | undefined; + do { + const { prompts, nextCursor } = await client.listPrompts({ cursor: promptCursor }); + allPrompts.push(...prompts); + promptCursor = nextCursor; + } while (promptCursor); + console.log( + 'Available prompts:', + allPrompts.map(p => p.name) + ); + + const { messages } = await client.getPrompt({ + name: 'review-code', + arguments: { code: 'console.log("hello")' } + }); + console.log(messages); + //#endregion getPrompt_basic +} + +/** Example: Request argument completions. */ +async function complete_basic(client: Client) { + //#region complete_basic + const { completion } = await client.complete({ + ref: { + type: 'ref/prompt', + name: 'review-code' + }, + argument: { + name: 'language', + value: 'type' + } + }); + console.log(completion.values); // e.g. ['typescript'] + //#endregion complete_basic +} + +// --------------------------------------------------------------------------- +// Notifications +// --------------------------------------------------------------------------- + +/** Example: Handle log messages and list-change notifications. */ +function notificationHandler_basic(client: Client) { + //#region notificationHandler_basic + // Server log messages (sent by the server during request processing) + client.setNotificationHandler('notifications/message', notification => { + const { level, data } = notification.params; + console.log(`[${level}]`, data); + }); + + // Server's resource list changed — re-fetch the list + client.setNotificationHandler('notifications/resources/list_changed', async () => { + const { resources } = await client.listResources(); + console.log('Resources changed:', resources.length); + }); + //#endregion notificationHandler_basic +} + +/** Example: Control server log level. */ +async function setLoggingLevel_basic(client: Client) { + //#region setLoggingLevel_basic + await client.setLoggingLevel('warning'); + //#endregion setLoggingLevel_basic +} + +/** Example: Automatic list-change tracking via the listChanged option. */ +async function listChanged_basic() { + //#region listChanged_basic + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } + ); + //#endregion listChanged_basic + return client; +} + +// --------------------------------------------------------------------------- +// Handling server-initiated requests +// --------------------------------------------------------------------------- + +/** Example: Declare client capabilities for sampling and elicitation. */ +function capabilities_declaration() { + //#region capabilities_declaration + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + capabilities: { + sampling: {}, + elicitation: { form: {} } + } + } + ); + //#endregion capabilities_declaration + return client; +} + +/** Example: Handle a sampling request from the server. */ +function sampling_handler(client: Client) { + //#region sampling_handler + client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { + type: 'text' as const, + text: 'Response from the model' + } + }; + }); + //#endregion sampling_handler +} + +/** Example: Handle an elicitation request from the server. */ +function elicitation_handler(client: Client) { + //#region elicitation_handler + client.setRequestHandler('elicitation/create', async request => { + console.log('Server asks:', request.params.message); + + if (request.params.mode === 'form') { + // Present the schema-driven form to the user + console.log('Schema:', request.params.requestedSchema); + return { action: 'accept', content: { confirm: true } }; + } + + return { action: 'decline' }; + }); + //#endregion elicitation_handler +} + +/** Example: Expose filesystem roots to the server. */ +function roots_handler(client: Client) { + //#region roots_handler + client.setRequestHandler('roots/list', async () => { + return { + roots: [ + { uri: 'file:///home/user/projects/my-app', name: 'My App' }, + { uri: 'file:///home/user/data', name: 'Data' } + ] + }; + }); + //#endregion roots_handler +} + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +/** Example: Tool errors vs protocol errors. */ +async function errorHandling_toolErrors(client: Client) { + //#region errorHandling_toolErrors + try { + const result = await client.callTool({ + name: 'fetch-data', + arguments: { url: 'https://example.com' } + }); + + // Tool-level error: the tool ran but reported a problem + if (result.isError) { + console.error('Tool error:', result.content); + return; + } + + console.log('Success:', result.content); + } catch (error) { + // Protocol-level error: the request itself failed + if (error instanceof ProtocolError) { + console.error(`Protocol error ${error.code}: ${error.message}`); + } else if (error instanceof SdkError) { + console.error(`SDK error [${error.code}]: ${error.message}`); + } else { + throw error; + } + } + //#endregion errorHandling_toolErrors +} + +/** Example: Connection lifecycle callbacks. */ +function errorHandling_lifecycle(client: Client) { + //#region errorHandling_lifecycle + // Out-of-band errors (SSE disconnects, parse errors) + client.onerror = error => { + console.error('Transport error:', error.message); + }; + + // Connection closed (pending requests are rejected with CONNECTION_CLOSED) + client.onclose = () => { + console.log('Connection closed'); + }; + //#endregion errorHandling_lifecycle +} + +/** Example: Custom timeouts. */ +async function errorHandling_timeout(client: Client) { + //#region errorHandling_timeout + try { + const result = await client.callTool( + { name: 'slow-task', arguments: {} }, + { timeout: 120_000 } // 2 minutes instead of the default 60 seconds + ); + console.log(result.content); + } catch (error) { + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + console.error('Request timed out'); + } + } + //#endregion errorHandling_timeout +} + +// --------------------------------------------------------------------------- +// Advanced patterns +// --------------------------------------------------------------------------- + +/** Example: Client middleware that adds a custom header. */ +async function middleware_basic() { + //#region middleware_basic + const authMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'my-value'); + return next(input, { ...init, headers }); + }); + + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { + fetch: applyMiddlewares(authMiddleware)(fetch) + }); + //#endregion middleware_basic + return transport; +} + +/** Example: Track resumption tokens for SSE reconnection. */ +async function resumptionToken_basic(client: Client) { + //#region resumptionToken_basic + let lastToken: string | undefined; + + const result = await client.request( + { + method: 'tools/call', + params: { name: 'long-running-task', arguments: {} } + }, + { + resumptionToken: lastToken, + onresumptiontoken: (token: string) => { + lastToken = token; + // Persist token to survive restarts + } + } + ); + console.log(result); + //#endregion resumptionToken_basic +} + +// Suppress unused-function warnings (functions exist solely for type-checking) +void connect_streamableHttp; +void connect_stdio; +void connect_sseFallback; +void disconnect_streamableHttp; +void serverInstructions_basic; +void auth_tokenProvider; +void auth_clientCredentials; +void auth_privateKeyJwt; +void auth_crossAppAccess; +void callTool_basic; +void callTool_structuredOutput; +void callTool_progress; +void readResource_basic; +void subscribeResource_basic; +void getPrompt_basic; +void complete_basic; +void notificationHandler_basic; +void setLoggingLevel_basic; +void listChanged_basic; +void capabilities_declaration; +void sampling_handler; +void elicitation_handler; +void roots_handler; +void errorHandling_toolErrors; +void errorHandling_lifecycle; +void errorHandling_timeout; +void middleware_basic; +void resumptionToken_basic; diff --git a/examples/client/src/customMethodExample.ts b/examples/client/src/customMethodExample.ts new file mode 100644 index 0000000..a289af0 --- /dev/null +++ b/examples/client/src/customMethodExample.ts @@ -0,0 +1,25 @@ +/** + * Custom (non-spec) method example: a client that sends `acme/search` and + * listens for `acme/searchProgress` notifications. + * + * Build `examples/server` first; this client spawns the server via stdio. + */ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { z } from 'zod/v4'; + +const SearchResult = z.object({ items: z.array(z.string()) }); +const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); + +const client = new Client({ name: 'acme-search-client', version: '0.0.0' }); + +client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { + console.log(`[progress] ${params.stage} ${Math.round(params.pct * 100)}%`); +}); + +await client.connect(new StdioClientTransport({ command: 'node', args: ['../server/dist/customMethodExample.js'] })); + +const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); +console.log('items:', result.items); + +await client.close(); diff --git a/examples/client/src/dualModeAuth.ts b/examples/client/src/dualModeAuth.ts new file mode 100644 index 0000000..4dd1ead --- /dev/null +++ b/examples/client/src/dualModeAuth.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * Two auth patterns through the same `authProvider` option. + * + * The transport accepts either a minimal `AuthProvider` (just `token()` + + * optional `onUnauthorized()`) or a full `OAuthClientProvider`, adapting + * the latter automatically. This means your connect/call code is identical + * regardless of which pattern fits your deployment. + * + * HOST-MANAGED — token lives in an enclosing app + * The app fetches and stores tokens; the MCP client just reads them. + * On 401, there is nothing to refresh — signal the UI and throw so the + * user can re-authenticate through the host's flow. + * + * USER-CONFIGURED — OAuth credentials supplied directly + * Pass a built-in or custom OAuthClientProvider. The transport handles + * the full OAuth flow: token refresh on 401, or redirect for interactive + * authorization. + */ + +import type { AuthProvider } from '@modelcontextprotocol/client'; +import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; + +// --- Stubs for host-app integration points --------------------------------- + +/** Whatever the host app uses to store session state (e.g., cookies, keychain, in-memory). */ +interface HostSessionStore { + getMcpToken(): string | undefined; +} + +/** Whatever the host app uses to surface UI prompts. */ +interface HostUi { + showReauthPrompt(message: string): void; +} + +// --- MODE A: Host-managed auth --------------------------------------------- + +function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, ui: HostUi): StreamableHTTPClientTransport { + const authProvider: AuthProvider = { + // Called before every request — just read whatever the host has. + token: async () => session.getMcpToken(), + + // Called on 401 — don't refresh (the host owns the token), signal the UI and bail. + // The transport will retry once after this returns, so we throw to stop it: + // the user needs to act before a retry makes sense. + onUnauthorized: async () => { + ui.showReauthPrompt('MCP connection lost — click to reconnect'); + throw new UnauthorizedError('Host token rejected — user action required'); + } + }; + + return new StreamableHTTPClientTransport(serverUrl, { authProvider }); +} + +// --- MODE B: User-configured OAuth ----------------------------------------- + +function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport { + // Built-in OAuth provider — the transport adapts it to AuthProvider internally. + // On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized, + // which runs token refresh (or redirect for interactive flows). + const authProvider = new ClientCredentialsProvider({ clientId, clientSecret }); + + return new StreamableHTTPClientTransport(serverUrl, { authProvider }); +} + +// --- Same caller code for both modes --------------------------------------- + +async function connectAndList(transport: StreamableHTTPClientTransport): Promise { + const client = new Client({ name: 'dual-mode-example', version: '1.0.0' }, { capabilities: {} }); + await client.connect(transport); + + const tools = await client.listTools(); + console.log('Tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); + + await transport.close(); +} + +// --- Driver ---------------------------------------------------------------- + +async function main() { + const serverUrl = new URL(process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'); + const mode = process.argv[2] || 'host'; + + let transport: StreamableHTTPClientTransport; + + if (mode === 'host') { + // Simulate a host app with a session-stored token and a UI hook. + const session: HostSessionStore = { getMcpToken: () => process.env.MCP_TOKEN }; + const ui: HostUi = { showReauthPrompt: msg => console.error(`[UI] ${msg}`) }; + transport = createHostManagedTransport(serverUrl, session, ui); + } else if (mode === 'oauth') { + const clientId = process.env.OAUTH_CLIENT_ID; + const clientSecret = process.env.OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode'); + process.exit(1); + } + transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret); + } else { + console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`); + process.exit(1); + } + + // Same connect/list code regardless of mode — the transport abstracts the difference. + await connectAndList(transport); +} + +try { + await main(); +} catch (error) { + console.error('Error:', error); + process.exitCode = 1; +} diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts new file mode 100644 index 0000000..7c5cce2 --- /dev/null +++ b/examples/client/src/elicitationUrlExample.ts @@ -0,0 +1,824 @@ +// Run with: pnpm tsx src/elicitationUrlExample.ts +// +// This example demonstrates how to use URL elicitation to securely +// collect user input in a remote (HTTP) server. +// URL elicitation allows servers to prompt the end-user to open a URL in their browser +// to collect sensitive information. + +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; + +import type { + ElicitRequest, + ElicitRequestURLParams, + ElicitResult, + ListToolsRequest, + OAuthClientMetadata, + ResourceLink +} from '@modelcontextprotocol/client'; +import { + Client, + getDisplayName, + ProtocolError, + ProtocolErrorCode, + StreamableHTTPClientTransport, + UnauthorizedError, + UrlElicitationRequiredError +} from '@modelcontextprotocol/client'; +import open from 'open'; + +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +// Set up OAuth (required for this example) +const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) +const OAUTH_CALLBACK_URL = `http://localhost:${OAUTH_CALLBACK_PORT}/callback`; + +console.log('Getting OAuth token...'); +const clientMetadata: OAuthClientMetadata = { + client_name: 'Elicitation MCP Client', + redirect_uris: [OAUTH_CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools' +}; +const oauthProvider = new InMemoryOAuthClientProvider(OAUTH_CALLBACK_URL, clientMetadata, (redirectUrl: URL) => { + console.log(`🌐 Opening browser for OAuth redirect: ${redirectUrl.toString()}`); + openBrowser(redirectUrl.toString()); +}); + +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); +let abortCommand = new AbortController(); + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; +let sessionId: string | undefined; + +// Elicitation queue management +interface QueuedElicitation { + request: ElicitRequest; + resolve: (result: ElicitResult) => void; + reject: (error: Error) => void; +} + +let isProcessingCommand = false; +let isProcessingElicitations = false; +const elicitationQueue: QueuedElicitation[] = []; +let elicitationQueueSignal: (() => void) | null = null; +let elicitationsCompleteSignal: (() => void) | null = null; + +// Map to track pending URL elicitations waiting for completion notifications +const pendingURLElicitations = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } +>(); + +async function main(): Promise { + console.log('MCP Interactive Client'); + console.log('====================='); + + // Connect to server immediately with default settings + await connect(); + + // Start the elicitation loop in the background + elicitationLoop().catch(error => { + console.error('Unexpected error in elicitation loop:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + }); + + // Short delay allowing the server to send any SSE elicitations on connection + await new Promise(resolve => setTimeout(resolve, 200)); + + // Wait until we are done processing any initial elicitations + await waitForElicitationsToComplete(); + + // Print help and start the command loop + printHelp(); + await commandLoop(); +} + +async function waitForElicitationsToComplete(): Promise { + // Wait until the queue is empty and nothing is being processed + while (elicitationQueue.length > 0 || isProcessingElicitations) { + await new Promise(resolve => setTimeout(resolve, 100)); + } +} + +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' payment-confirm - Test URL elicitation via error response with payment-confirm tool'); + console.log(' third-party-auth - Test tool that requires third-party OAuth credentials'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +async function commandLoop(): Promise { + await new Promise(resolve => { + if (isProcessingElicitations) { + elicitationsCompleteSignal = resolve; + } else { + resolve(); + } + }); + + readline.question('\n> ', { signal: abortCommand.signal }, async input => { + isProcessingCommand = true; + + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': { + await connect(args[1]); + break; + } + + case 'disconnect': { + await disconnect(); + break; + } + + case 'terminate-session': { + await terminateSession(); + break; + } + + case 'reconnect': { + await reconnect(); + break; + } + + case 'list-tools': { + await listTools(); + break; + } + + case 'call-tool': { + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]!; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + } + + case 'payment-confirm': { + await callPaymentConfirmTool(); + break; + } + + case 'third-party-auth': { + await callThirdPartyAuthTool(); + break; + } + + case 'help': { + printHelp(); + break; + } + + case 'quit': + case 'exit': { + await cleanup(); + return; + } + + default: { + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } finally { + isProcessingCommand = false; + } + + // Process another command after we've processed the this one + await commandLoop(); + }); +} + +async function elicitationLoop(): Promise { + while (true) { + // Wait until we have elicitations to process + await new Promise(resolve => { + if (elicitationQueue.length > 0) { + resolve(); + } else { + elicitationQueueSignal = resolve; + } + }); + + isProcessingElicitations = true; + abortCommand.abort(); // Abort the command loop if it's running + + // Process all queued elicitations + while (elicitationQueue.length > 0) { + const queued = elicitationQueue.shift()!; + console.log(`📤 Processing queued elicitation (${elicitationQueue.length} remaining)`); + + try { + const result = await handleElicitationRequest(queued.request); + queued.resolve(result); + } catch (error) { + queued.reject(error instanceof Error ? error : new Error(String(error))); + } + } + + console.log('✅ All queued elicitations processed. Resuming command loop...\n'); + isProcessingElicitations = false; + + // Reset the abort controller for the next command loop + abortCommand = new AbortController(); + + // Resume the command loop + if (elicitationsCompleteSignal) { + elicitationsCompleteSignal(); + elicitationsCompleteSignal = null; + } + } +} + +const ALLOWED_SCHEMES = new Set(['http:', 'https:']); + +async function openBrowser(url: string): Promise { + try { + const parsed = new URL(url); + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`); + return; + } + } catch { + console.error(`Invalid URL: ${url}`); + return; + } + + try { + await open(url); + } catch { + console.log(`Please manually open: ${url}`); + } +} + +/** + * Enqueues an elicitation request and returns the result. + * + * This function is used so that our CLI (which can only handle one input request at a time) + * can handle elicitation requests and the command loop. + * + * @param request - The elicitation request to be handled + * @returns The elicitation result + */ +async function elicitationRequestHandler(request: ElicitRequest): Promise { + // If we are processing a command, handle this elicitation immediately + if (isProcessingCommand) { + console.log('📋 Processing elicitation immediately (during command execution)'); + return await handleElicitationRequest(request); + } + + // Otherwise, queue the request to be handled by the elicitation loop + console.log(`📥 Queueing elicitation request (queue size will be: ${elicitationQueue.length + 1})`); + + return new Promise((resolve, reject) => { + elicitationQueue.push({ + request, + resolve, + reject + }); + + // Signal the elicitation loop that there's work to do + if (elicitationQueueSignal) { + elicitationQueueSignal(); + elicitationQueueSignal = null; + } + }); +} + +/** + * Handles an elicitation request. + * + * This function is used to handle the elicitation request and return the result. + * + * @param request - The elicitation request to be handled + * @returns The elicitation result + */ +async function handleElicitationRequest(request: ElicitRequest): Promise { + const mode = request.params.mode; + console.log('\n🔔 Elicitation Request Received:'); + console.log(`Mode: ${mode}`); + + if (mode === 'url') { + return { + action: await handleURLElicitation(request.params as ElicitRequestURLParams) + }; + } else { + // Should not happen because the client declares its capabilities to the server, + // but being defensive is a good practice: + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${mode}`); + } +} + +/** + * Handles a URL elicitation by opening the URL in the browser. + * + * Note: This is a shared code for both request handlers and error handlers. + * As a result of sharing schema, there is no big forking of logic for the client. + * + * @param params - The URL elicitation request parameters + * @returns The action to take (accept, cancel, or decline) + */ +async function handleURLElicitation(params: ElicitRequestURLParams): Promise { + const url = params.url; + const elicitationId = params.elicitationId; + const message = params.message; + console.log(`🆔 Elicitation ID: ${elicitationId}`); // Print for illustration + + // Parse URL to show domain for security + let domain = 'unknown domain'; + try { + const parsedUrl = new URL(url); + domain = parsedUrl.hostname; + } catch { + console.error('Invalid URL provided by server'); + return 'decline'; + } + + // Example security warning to help prevent phishing attacks + console.log('\n⚠️ \u001B[33mSECURITY WARNING\u001B[0m ⚠️'); + console.log('\u001B[33mThe server is requesting you to open an external URL.\u001B[0m'); + console.log('\u001B[33mOnly proceed if you trust this server and understand why it needs this.\u001B[0m\n'); + console.log(`🌐 Target domain: \u001B[36m${domain}\u001B[0m`); + console.log(`🔗 Full URL: \u001B[36m${url}\u001B[0m`); + console.log(`\nℹ️ Server's reason:\n\n\u001B[36m${message}\u001B[0m\n`); + + // 1. Ask for user consent to open the URL + const consent = await new Promise(resolve => { + readline.question('\nDo you want to open this URL in your browser? (y/n): ', input => { + resolve(input.trim().toLowerCase()); + }); + }); + + // 2. If user did not consent, return appropriate result + if (consent === 'no' || consent === 'n') { + console.log('❌ URL navigation declined.'); + return 'decline'; + } else if (consent !== 'yes' && consent !== 'y') { + console.log('🚫 Invalid response. Cancelling elicitation.'); + return 'cancel'; + } + + // 3. Wait for completion notification in the background + const completionPromise = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => { + pendingURLElicitations.delete(elicitationId); + console.log(`\u001B[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\u001B[0m`); + reject(new Error('Elicitation completion timeout')); + }, + 5 * 60 * 1000 + ); // 5 minute timeout + + pendingURLElicitations.set(elicitationId, { + resolve: () => { + clearTimeout(timeout); + resolve(); + }, + reject, + timeout + }); + }); + + completionPromise.catch(error => { + console.error('Background completion wait failed:', error); + }); + + // 4. Open the URL in the browser + console.log(`\n🚀 Opening browser to: ${url}`); + await openBrowser(url); + + console.log('\n⏳ Waiting for you to complete the interaction in your browser...'); + console.log(' The server will send a notification once you complete the action.'); + + // 5. Acknowledge the user accepted the elicitation + return 'accept'; +} + +/** + * Example OAuth callback handler - in production, use a more robust approach + * for handling callbacks and storing tokens + */ +/** + * Starts a temporary HTTP server to receive the OAuth callback + */ +async function waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`📥 Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

This simulates successful authorization of the MCP client, which now has an access token for the MCP server.

+

This window will close automatically in 10 seconds.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 15_000); + } else if (error) { + console.log(`❌ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`❌ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(OAUTH_CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${OAUTH_CALLBACK_PORT}`); + }); + }); +} + +/** + * Attempts to connect to the MCP server with OAuth authentication. + * Handles OAuth flow recursively if authorization is required. + */ +async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { + console.log('🚢 Creating transport with OAuth provider...'); + const baseUrl = new URL(serverUrl); + transport = new StreamableHTTPClientTransport(baseUrl, { + sessionId: sessionId, + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('🔌 Attempting connection (this will trigger OAuth redirect if needed)...'); + await client!.connect(transport); + sessionId = transport.sessionId; + console.log('Transport created with session ID:', sessionId); + console.log('✅ Connected successfully'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('🔐 OAuth required - waiting for authorization...'); + const callbackPromise = waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + // Recursively retry connection after OAuth completion + await attemptConnection(oauthProvider); + } else { + console.error('❌ Connection failed with non-auth error:', error); + throw error; + } + } +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`🔗 Attempting to connect to ${serverUrl}...`); + + // Create a new client with elicitation capability + console.log('👤 Creating MCP client...'); + client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + // Only URL elicitation is supported in this demo + // (see server/elicitationExample.ts for a demo of form mode elicitation) + url: {} + } + } + } + ); + console.log('👤 Client created'); + + // Set up elicitation request handler with proper validation + client.setRequestHandler('elicitation/create', elicitationRequestHandler); + + // Set up notification handler for elicitation completion + client.setNotificationHandler('notifications/elicitation/complete', notification => { + const { elicitationId } = notification.params; + const pending = pendingURLElicitations.get(elicitationId); + if (pending) { + clearTimeout(pending.timeout); + pendingURLElicitations.delete(elicitationId); + console.log(`\u001B[32m✅ Elicitation ${elicitationId} completed!\u001B[0m`); + pending.resolve(); + } else { + // Shouldn't happen - discard it! + console.warn(`Received completion notification for unknown elicitation: ${elicitationId}`); + } + }); + + try { + console.log('🔐 Starting OAuth flow...'); + await attemptConnection(oauthProvider!); + console.log('Connected to MCP server'); + + // Set up error handler after connection is established so we don't double log errors + client.onerror = error => { + console.error('\u001B[31mClient error:', error, '\u001B[0m'); + }; + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + return; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (transport.sessionId) { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } else { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } +} + +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.callTool({ name, arguments: args }); + + console.log('Tool result:'); + const resourceLinks: ResourceLink[] = []; + + for (const item of result.content) { + switch (item.type) { + case 'text': { + console.log(` ${item.text}`); + + break; + } + case 'resource_link': { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + + break; + } + case 'resource': { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + + break; + } + case 'image': { + console.log(` [Image: ${item.mimeType}]`); + + break; + } + case 'audio': { + console.log(` [Audio: ${item.mimeType}]`); + + break; + } + default: { + console.log(` [Unknown content type]:`, item); + } + } + } + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } + } catch (error) { + if (error instanceof UrlElicitationRequiredError) { + console.log('\n🔔 Elicitation Required Error Received:'); + console.log(`Message: ${error.message}`); + for (const e of error.elicitations) { + await handleURLElicitation(e); // For the error handler, we discard the action result because we don't respond to an error response + } + return; + } + console.log(`Error calling tool ${name}: ${error}`); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + process.stdin.setRawMode(false); + readline.close(); + console.log('\nGoodbye!'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +} + +async function callPaymentConfirmTool(): Promise { + console.log('Calling payment-confirm tool...'); + await callTool('payment-confirm', { cartId: 'cart_123' }); +} + +async function callThirdPartyAuthTool(): Promise { + console.log('Calling third-party-auth tool...'); + await callTool('third-party-auth', { param1: 'test' }); +} + +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async data => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client +try { + await main(); +} catch (error) { + console.error('Error running MCP client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/multipleClientsParallel.ts b/examples/client/src/multipleClientsParallel.ts new file mode 100644 index 0000000..6543bae --- /dev/null +++ b/examples/client/src/multipleClientsParallel.ts @@ -0,0 +1,152 @@ +import type { CallToolResult } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +/** + * Multiple Clients MCP Example + * + * This client demonstrates how to: + * 1. Create multiple MCP clients in parallel + * 2. Each client calls a single tool + * 3. Track notifications from each client independently + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +interface ClientConfig { + id: string; + name: string; + toolName: string; + toolArguments: Record; +} + +async function createAndRunClient(config: ClientConfig): Promise<{ id: string; result: CallToolResult }> { + console.log(`[${config.id}] Creating client: ${config.name}`); + + const client = new Client({ + name: config.name, + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + // Set up client-specific error handler + client.onerror = error => { + console.error(`[${config.id}] Client error:`, error); + }; + + // Set up client-specific notification handler + client.setNotificationHandler('notifications/message', notification => { + console.log(`[${config.id}] Notification: ${notification.params.data}`); + }); + + try { + // Connect to the server + await client.connect(transport); + console.log(`[${config.id}] Connected to MCP server`); + + // Call the specified tool + console.log(`[${config.id}] Calling tool: ${config.toolName}`); + const result = await client.callTool({ + name: config.toolName, + arguments: { + ...config.toolArguments, + // Add client ID to arguments for identification in notifications + caller: config.id + } + }); + console.log(`[${config.id}] Tool call completed`); + + // Keep the connection open for a bit to receive notifications + await new Promise(resolve => setTimeout(resolve, 5000)); + + // Disconnect + await transport.close(); + console.log(`[${config.id}] Disconnected from MCP server`); + + return { id: config.id, result }; + } catch (error) { + console.error(`[${config.id}] Error:`, error); + throw error; + } +} + +async function main(): Promise { + console.log('MCP Multiple Clients Example'); + console.log('============================'); + console.log(`Server URL: ${serverUrl}`); + console.log(''); + + try { + // Define client configurations + const clientConfigs: ClientConfig[] = [ + { + id: 'client1', + name: 'basic-client-1', + toolName: 'start-notification-stream', + toolArguments: { + interval: 3, // 1 second between notifications + count: 5 // Send 5 notifications + } + }, + { + id: 'client2', + name: 'basic-client-2', + toolName: 'start-notification-stream', + toolArguments: { + interval: 2, // 2 seconds between notifications + count: 3 // Send 3 notifications + } + }, + { + id: 'client3', + name: 'basic-client-3', + toolName: 'start-notification-stream', + toolArguments: { + interval: 1, // 0.5 second between notifications + count: 8 // Send 8 notifications + } + } + ]; + + // Start all clients in parallel + console.log(`Starting ${clientConfigs.length} clients in parallel...`); + console.log(''); + + const clientPromises = clientConfigs.map(config => createAndRunClient(config)); + const results = await Promise.all(clientPromises); + + // Display results from all clients + console.log('\n=== Final Results ==='); + for (const { id, result } of results) { + console.log(`\n[${id}] Tool result:`); + if (Array.isArray(result.content)) { + for (const item of result.content) { + if (item.type === 'text' && item.text) { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + } + } else { + console.log(` Unexpected result format:`, result); + } + } + + console.log('\n=== All clients completed successfully ==='); + } catch (error) { + console.error('Error running multiple clients:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +} + +// Start the example +try { + await main(); +} catch (error) { + console.error('Error running multiple clients:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/parallelToolCallsClient.ts b/examples/client/src/parallelToolCallsClient.ts new file mode 100644 index 0000000..5b16cc9 --- /dev/null +++ b/examples/client/src/parallelToolCallsClient.ts @@ -0,0 +1,175 @@ +import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +/** + * Parallel Tool Calls MCP Client + * + * This client demonstrates how to: + * 1. Start multiple tool calls in parallel + * 2. Track notifications from each tool call using a caller parameter + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Parallel Tool Calls Client'); + console.log('=============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport; + + try { + // Create client with streamable HTTP transport + client = new Client({ + name: 'parallel-tool-calls-client', + version: '1.0.0' + }); + + client.onerror = error => { + console.error('Client error:', error); + }; + + // Connect to the server + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + await client.connect(transport); + console.log('Successfully connected to MCP server'); + + // Set up notification handler with caller identification + client.setNotificationHandler('notifications/message', notification => { + console.log(`Notification: ${notification.params.data}`); + }); + + console.log('List tools'); + const toolsRequest = await listTools(client); + console.log('Tools:', toolsRequest); + + // 2. Start multiple notification tools in parallel + console.log('\n=== Starting Multiple Notification Streams in Parallel ==='); + const toolResults = await startParallelNotificationTools(client); + + // Log the results from each tool call + for (const [caller, result] of Object.entries(toolResults)) { + console.log(`\n=== Tool result for ${caller} ===`); + for (const item of result.content) { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + } + } + + // 3. Wait for all notifications (10 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 10_000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + } catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start multiple notification tools in parallel with different configurations + * Each tool call includes a caller parameter to identify its notifications + */ +async function startParallelNotificationTools(client: Client): Promise> { + try { + // Define multiple tool calls with different configurations + const toolCalls = [ + { + caller: 'fast-notifier', + args: { + interval: 2, // 0.5 second between notifications + count: 10, // Send 10 notifications + caller: 'fast-notifier' // Identify this tool call + } + }, + { + caller: 'slow-notifier', + args: { + interval: 5, // 2 seconds between notifications + count: 5, // Send 5 notifications + caller: 'slow-notifier' // Identify this tool call + } + }, + { + caller: 'burst-notifier', + args: { + interval: 1, // 0.1 second between notifications + count: 3, // Send just 3 notifications + caller: 'burst-notifier' // Identify this tool call + } + } + ]; + + console.log(`Starting ${toolCalls.length} notification tools in parallel...`); + + // Start all tool calls in parallel + const toolPromises = toolCalls.map(({ caller, args }) => { + console.log(`Starting tool call for ${caller}...`); + return client + .callTool({ name: 'start-notification-stream', arguments: args }) + .then(result => ({ caller, result })) + .catch(error => { + console.error(`Error in tool call for ${caller}:`, error); + throw error; + }); + }); + + // Wait for all tool calls to complete + const results = await Promise.all(toolPromises); + + // Organize results by caller + const resultsByTool: Record = {}; + for (const { caller, result } of results) { + resultsByTool[caller] = result; + } + + return resultsByTool; + } catch (error) { + console.error(`Error starting parallel notification tools:`, error); + throw error; + } +} + +try { + // Run the client + await main(); +} catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/simpleClientCredentials.ts b/examples/client/src/simpleClientCredentials.ts new file mode 100644 index 0000000..58f17e3 --- /dev/null +++ b/examples/client/src/simpleClientCredentials.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +/** + * Example demonstrating client_credentials grant for machine-to-machine authentication. + * + * Supports two authentication methods based on environment variables: + * + * 1. client_secret_basic (default): + * MCP_CLIENT_ID - OAuth client ID (required) + * MCP_CLIENT_SECRET - OAuth client secret (required) + * + * 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set): + * MCP_CLIENT_ID - OAuth client ID (required) + * MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required) + * MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256) + * + * Common: + * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + */ + +import type { OAuthClientProvider } from '@modelcontextprotocol/client'; +import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +function createProvider(): OAuthClientProvider { + const clientId = process.env.MCP_CLIENT_ID; + if (!clientId) { + console.error('MCP_CLIENT_ID environment variable is required'); + process.exit(1); + } + + // If private key is provided, use private_key_jwt authentication + const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM; + if (privateKeyPem) { + const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256'; + console.log('Using private_key_jwt authentication'); + return new PrivateKeyJwtProvider({ + clientId, + privateKey: privateKeyPem, + algorithm + }); + } + + // Otherwise, use client_secret_basic authentication + const clientSecret = process.env.MCP_CLIENT_SECRET; + if (!clientSecret) { + console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required'); + process.exit(1); + } + + console.log('Using client_secret_basic authentication'); + return new ClientCredentialsProvider({ + clientId, + clientSecret + }); +} + +async function main() { + const provider = createProvider(); + + const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { + authProvider: provider + }); + + await client.connect(transport); + console.log('Connected successfully.'); + + const tools = await client.listTools(); + console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); + + await transport.close(); +} + +try { + await main(); +} catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts new file mode 100644 index 0000000..c75aea9 --- /dev/null +++ b/examples/client/src/simpleOAuthClient.ts @@ -0,0 +1,469 @@ +#!/usr/bin/env node + +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { URL } from 'node:url'; + +import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client'; +import open from 'open'; + +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; + +// Configuration +const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const CALLBACK_PORT = 8090; // Use different port than auth server (3001) +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + +/** + * Interactive MCP client with OAuth authentication + * Demonstrates the complete OAuth flow with browser-based authorization + */ +class InteractiveOAuthClient { + private client: Client | null = null; + private readonly rl = createInterface({ + input: process.stdin, + output: process.stdout + }); + + constructor( + private serverUrl: string, + private clientMetadataUrl?: string + ) {} + + /** + * Prompts user for input via readline + */ + private async question(query: string): Promise { + return new Promise(resolve => { + this.rl.question(query, resolve); + }); + } + + /** + * Opens the authorization URL in the user's default browser + */ + private static readonly ALLOWED_SCHEMES = new Set(['http:', 'https:']); + + private async openBrowser(url: string): Promise { + console.log(`🌐 Opening browser for authorization: ${url}`); + + try { + const parsed = new URL(url); + if (!InteractiveOAuthClient.ALLOWED_SCHEMES.has(parsed.protocol)) { + console.error(`Refusing to open URL with unsupported scheme '${parsed.protocol}': ${url}`); + return; + } + } catch { + console.error(`Invalid URL: ${url}`); + return; + } + + try { + await open(url); + } catch { + console.log(`Please manually open: ${url}`); + } + } + /** + * Example OAuth callback handler - in production, use a more robust approach + * for handling callbacks and storing tokens + */ + /** + * Starts a temporary HTTP server to receive the OAuth callback + */ + private async waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`📥 Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`✅ Authorization code received: ${code?.slice(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 3000); + } else if (error) { + console.log(`❌ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`❌ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`); + }); + }); + } + + private async attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Promise { + console.log('🚢 Creating transport with OAuth provider...'); + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('🔌 Attempting connection (this will trigger OAuth redirect)...'); + await this.client!.connect(transport); + console.log('✅ Connected successfully'); + } catch (error) { + if (error instanceof UnauthorizedError) { + console.log('🔐 OAuth required - waiting for authorization...'); + const callbackPromise = this.waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('🔐 Authorization code received:', authCode); + console.log('🔌 Reconnecting with authenticated transport...'); + await this.attemptConnection(oauthProvider); + } else { + console.error('❌ Connection failed with non-auth error:', error); + throw error; + } + } + } + + /** + * Establishes connection to the MCP server with OAuth authentication + */ + async connect(): Promise { + console.log(`🔗 Attempting to connect to ${this.serverUrl}...`); + + const clientMetadata: OAuthClientMetadata = { + client_name: 'Simple OAuth MCP Client', + redirect_uris: [CALLBACK_URL], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + + console.log('🔐 Creating OAuth provider...'); + const oauthProvider = new InMemoryOAuthClientProvider( + CALLBACK_URL, + clientMetadata, + (redirectUrl: URL) => { + console.log(`📌 OAuth redirect handler called - opening browser`); + console.log(`Opening browser to: ${redirectUrl.toString()}`); + this.openBrowser(redirectUrl.toString()); + }, + this.clientMetadataUrl + ); + console.log('🔐 OAuth provider created'); + + console.log('👤 Creating MCP client...'); + this.client = new Client( + { + name: 'simple-oauth-client', + version: '1.0.0' + }, + { capabilities: {} } + ); + console.log('👤 Client created'); + + console.log('🔐 Starting OAuth flow...'); + + await this.attemptConnection(oauthProvider); + + // Start interactive loop + await this.interactiveLoop(); + } + + /** + * Main interactive loop for user commands + */ + async interactiveLoop(): Promise { + console.log('\n🎯 Interactive MCP Client with OAuth'); + console.log('Commands:'); + console.log(' list - List available tools'); + console.log(' call [args] - Call a tool'); + console.log(' stream [args] - Call a tool with streaming (shows task status)'); + console.log(' quit - Exit the client'); + console.log(); + + while (true) { + try { + const command = await this.question('mcp> '); + + if (!command.trim()) { + continue; + } + + if (command === 'quit') { + console.log('\n👋 Goodbye!'); + this.close(); + process.exit(0); + } else if (command === 'list') { + await this.listTools(); + } else if (command.startsWith('call ')) { + await this.handleCallTool(command); + } else if (command.startsWith('stream ')) { + await this.handleStreamTool(command); + } else { + console.log("❌ Unknown command. Try 'list', 'call ', 'stream ', or 'quit'"); + } + } catch (error) { + if (error instanceof Error && error.message === 'SIGINT') { + console.log('\n\n👋 Goodbye!'); + break; + } + console.error('❌ Error:', error); + } + } + } + + private async listTools(): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const request: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + + const result = await this.client.request(request); + + if (result.tools && result.tools.length > 0) { + console.log('\n📋 Available tools:'); + for (const [index, tool] of result.tools.entries()) { + console.log(`${index + 1}. ${tool.name}`); + if (tool.description) { + console.log(` Description: ${tool.description}`); + } + console.log(); + } + } else { + console.log('No tools available'); + } + } catch (error) { + console.error('❌ Failed to list tools:', error); + } + } + + private async handleCallTool(command: string): Promise { + const parts = command.split(/\s+/); + const toolName = parts[1]; + + if (!toolName) { + console.log('❌ Please specify a tool name'); + return; + } + + // Parse arguments (simple JSON-like format) + let toolArgs: Record = {}; + if (parts.length > 2) { + const argsString = parts.slice(2).join(' '); + try { + toolArgs = JSON.parse(argsString); + } catch { + console.log('❌ Invalid arguments format (expected JSON)'); + return; + } + } + + await this.callTool(toolName, toolArgs); + } + + private async callTool(toolName: string, toolArgs: Record): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + const result = await this.client.callTool({ + name: toolName, + arguments: toolArgs + }); + + console.log(`\n🔧 Tool '${toolName}' result:`); + if (result.content) { + for (const content of result.content) { + if (content.type === 'text') { + console.log(content.text); + } else { + console.log(content); + } + } + } else { + console.log(result); + } + } catch (error) { + console.error(`❌ Failed to call tool '${toolName}':`, error); + } + } + + private async handleStreamTool(command: string): Promise { + const parts = command.split(/\s+/); + const toolName = parts[1]; + + if (!toolName) { + console.log('❌ Please specify a tool name'); + return; + } + + // Parse arguments (simple JSON-like format) + let toolArgs: Record = {}; + if (parts.length > 2) { + const argsString = parts.slice(2).join(' '); + try { + toolArgs = JSON.parse(argsString); + } catch { + console.log('❌ Invalid arguments format (expected JSON)'); + return; + } + } + + await this.streamTool(toolName, toolArgs); + } + + private async streamTool(toolName: string, toolArgs: Record): Promise { + if (!this.client) { + console.log('❌ Not connected to server'); + return; + } + + try { + // Using the experimental tasks API - WARNING: may change without notice + console.log(`\n🔧 Streaming tool '${toolName}'...`); + + const stream = this.client.experimental.tasks.callToolStream( + { + name: toolName, + arguments: toolArgs + }, + { + task: { + taskId: `task-${Date.now()}`, + ttl: 60_000 + } + } + ); + + // Iterate through all messages yielded by the generator + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log(`✓ Task created: ${message.task.taskId}`); + break; + } + + case 'taskStatus': { + console.log(`⟳ Status: ${message.task.status}`); + if (message.task.statusMessage) { + console.log(` ${message.task.statusMessage}`); + } + break; + } + + case 'result': { + console.log('✓ Completed!'); + const toolResult = message.result as CallToolResult; + for (const content of toolResult.content) { + if (content.type === 'text') { + console.log(content.text); + } else { + console.log(content); + } + } + break; + } + + case 'error': { + console.log('✗ Error:'); + console.log(` ${message.error.message}`); + break; + } + } + } + } catch (error) { + console.error(`❌ Failed to stream tool '${toolName}':`, error); + } + } + + close(): void { + this.rl.close(); + if (this.client) { + // Note: Client doesn't have a close method in the current implementation + // This would typically close the transport connection + } + } +} + +/** + * Main entry point + */ +async function main(): Promise { + const args = process.argv.slice(2); + const serverUrl = args[0] || DEFAULT_SERVER_URL; + const clientMetadataUrl = args[1]; + + console.log('🚀 Simple MCP OAuth Client'); + console.log(`Connecting to: ${serverUrl}`); + if (clientMetadataUrl) { + console.log(`Client Metadata URL: ${clientMetadataUrl}`); + } + console.log(); + + const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl); + + // Handle graceful shutdown + process.on('SIGINT', () => { + console.log('\n\n👋 Goodbye!'); + client.close(); + process.exit(0); + }); + + try { + await client.connect(); + } catch (error) { + console.error('Failed to start client:', error); + process.exit(1); + } finally { + client.close(); + } +} + +try { + // Run if this file is executed directly + await main(); +} catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/simpleOAuthClientProvider.ts b/examples/client/src/simpleOAuthClientProvider.ts new file mode 100644 index 0000000..1ef0827 --- /dev/null +++ b/examples/client/src/simpleOAuthClientProvider.ts @@ -0,0 +1,69 @@ +import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/client'; +import { validateClientMetadataUrl } from '@modelcontextprotocol/client'; + +/** + * In-memory OAuth client provider for demonstration purposes + * In production, you should persist tokens securely + */ +export class InMemoryOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationMixed; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + onRedirect?: (url: URL) => void, + public readonly clientMetadataUrl?: string + ) { + // Validate clientMetadataUrl at construction time (fail-fast) + validateClientMetadataUrl(clientMetadataUrl); + + this._onRedirect = + onRedirect || + (url => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformationMixed | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationMixed): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/examples/client/src/simpleStreamableHttp.ts b/examples/client/src/simpleStreamableHttp.ts new file mode 100644 index 0000000..f22d16b --- /dev/null +++ b/examples/client/src/simpleStreamableHttp.ts @@ -0,0 +1,1008 @@ +import { createInterface } from 'node:readline'; + +import type { + CallToolResult, + GetPromptRequest, + ListPromptsRequest, + ListResourcesRequest, + ListToolsRequest, + ReadResourceRequest, + ResourceLink +} from '@modelcontextprotocol/client'; +import { + Client, + getDisplayName, + InMemoryTaskStore, + ProtocolError, + ProtocolErrorCode, + RELATED_TASK_META_KEY, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; +import { Ajv } from 'ajv'; + +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Track received notifications for debugging resumability +let notificationCount = 0; + +// Global client and transport for interactive commands +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; +let notificationsToolLastEventId: string | undefined; +let sessionId: string | undefined; + +async function main(): Promise { + console.log('MCP Interactive Client'); + console.log('====================='); + + // Connect to server immediately with default settings + await connect(); + + // Print help and start the command loop + printHelp(); + commandLoop(); +} + +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' terminate-session - Terminate the current session'); + console.log(' reconnect - Reconnect to the server'); + console.log(' list-tools - List available tools'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' call-tool-task [args] - Call a tool with task-based execution (example: call-tool-task delay {"duration":3000})'); + console.log(' greet [name] - Call the greet tool'); + console.log(' multi-greet [name] - Call the multi-greet tool with notifications'); + console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)'); + console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation'); + console.log(' start-notifications [interval] [count] - Start periodic notifications'); + console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability'); + console.log(' list-prompts - List available prompts'); + console.log(' get-prompt [name] [args] - Get a prompt with optional JSON arguments'); + console.log(' list-resources - List available resources'); + console.log(' read-resource - Read a specific resource by URI'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +function commandLoop(): void { + readline.question('\n> ', async input => { + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': { + await connect(args[1]); + break; + } + + case 'disconnect': { + await disconnect(); + break; + } + + case 'terminate-session': { + await terminateSession(); + break; + } + + case 'reconnect': { + await reconnect(); + break; + } + + case 'list-tools': { + await listTools(); + break; + } + + case 'call-tool': { + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]!; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + } + + case 'greet': { + await callGreetTool(args[1] || 'MCP User'); + break; + } + + case 'multi-greet': { + await callMultiGreetTool(args[1] || 'MCP User'); + break; + } + + case 'collect-info': { + await callCollectInfoTool(args[1] || 'contact'); + break; + } + + case 'collect-info-task': { + await callCollectInfoWithTask(args[1] || 'contact'); + break; + } + + case 'start-notifications': { + const interval = args[1] ? Number.parseInt(args[1], 10) : 2000; + const count = args[2] ? Number.parseInt(args[2], 10) : 10; + await startNotifications(interval, count); + break; + } + + case 'run-notifications-tool-with-resumability': { + const interval = args[1] ? Number.parseInt(args[1], 10) : 2000; + const count = args[2] ? Number.parseInt(args[2], 10) : 10; + await runNotificationsToolWithResumability(interval, count); + break; + } + + case 'call-tool-task': { + if (args.length < 2) { + console.log('Usage: call-tool-task [args]'); + } else { + const toolName = args[1]!; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callToolTask(toolName, toolArgs); + } + break; + } + + case 'list-prompts': { + await listPrompts(); + break; + } + + case 'get-prompt': { + if (args.length < 2) { + console.log('Usage: get-prompt [args]'); + } else { + const promptName = args[1]!; + let promptArgs = {}; + if (args.length > 2) { + try { + promptArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await getPrompt(promptName, promptArgs); + } + break; + } + + case 'list-resources': { + await listResources(); + break; + } + + case 'read-resource': { + if (args.length < 2) { + console.log('Usage: read-resource '); + } else { + await readResource(args[1]!); + } + break; + } + + case 'help': { + printHelp(); + break; + } + + case 'quit': + case 'exit': { + await cleanup(); + return; + } + + default: { + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } + + // Continue the command loop + commandLoop(); + }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`Connecting to ${serverUrl}...`); + + try { + // Create task store for client-side task support + const clientTaskStore = new InMemoryTaskStore(); + + // Create a new client with form elicitation capability and task support + client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + }, + tasks: { + taskStore: clientTaskStore, + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + client.onerror = error => { + console.error('\u001B[31mClient error:', error, '\u001B[0m'); + }; + + // Set up elicitation request handler with proper validation and task support + client.setRequestHandler('elicitation/create', async (request, extra) => { + if (request.params.mode !== 'form') { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + console.log('\n🔔 Elicitation (form) Request Received:'); + console.log(`Message: ${request.params.message}`); + console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`); + console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`); + console.log('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + // Helper to return result, optionally creating a task if requested + const returnResult = async (result: { + action: 'accept' | 'decline' | 'cancel'; + content?: Record; + }) => { + if (request.params.task && extra.task?.store) { + // Create a task and store the result + const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); + await extra.task.store.storeTaskResult(task.taskId, 'completed', result); + console.log(`📋 Created client-side task: ${task.taskId}`); + return { task }; + } + return result; + }; + + const schema = request.params.requestedSchema; + const properties = schema.properties; + const required = schema.required || []; + + // Set up AJV validator for the requested schema + const ajv = new Ajv(); + const validate = ajv.compile(schema); + + let attempts = 0; + const maxAttempts = 3; + + while (attempts < maxAttempts) { + attempts++; + console.log(`\nPlease provide the following information (attempt ${attempts}/${maxAttempts}):`); + + const content: Record = {}; + let inputCancelled = false; + + // Collect input for each field + for (const [fieldName, fieldSchema] of Object.entries(properties)) { + const field = fieldSchema as { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + format?: string; + }; + + const isRequired = required.includes(fieldName); + let prompt = `${field.title || fieldName}`; + + // Add helpful information to the prompt + if (field.description) { + prompt += ` (${field.description})`; + } + if (field.enum) { + prompt += ` [options: ${field.enum.join(', ')}]`; + } + if (field.type === 'number' || field.type === 'integer') { + if (field.minimum !== undefined && field.maximum !== undefined) { + prompt += ` [${field.minimum}-${field.maximum}]`; + } else if (field.minimum !== undefined) { + prompt += ` [min: ${field.minimum}]`; + } else if (field.maximum !== undefined) { + prompt += ` [max: ${field.maximum}]`; + } + } + if (field.type === 'string' && field.format) { + prompt += ` [format: ${field.format}]`; + } + if (isRequired) { + prompt += ' *required*'; + } + if (field.default !== undefined) { + prompt += ` [default: ${field.default}]`; + } + + prompt += ': '; + + const answer = await new Promise(resolve => { + readline.question(prompt, input => { + resolve(input.trim()); + }); + }); + + // Check for cancellation + if (answer.toLowerCase() === 'cancel' || answer.toLowerCase() === 'c') { + inputCancelled = true; + break; + } + + // Parse and validate the input + try { + if (answer === '' && field.default !== undefined) { + content[fieldName] = field.default as string | number | boolean | string[]; + } else if (answer === '' && !isRequired) { + // Skip optional empty fields + continue; + } else if (answer === '') { + throw new Error(`${fieldName} is required`); + } else { + // Parse the value based on type + let parsedValue: unknown; + + switch (field.type) { + case 'boolean': { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + + break; + } + case 'number': { + parsedValue = Number.parseFloat(answer); + if (Number.isNaN(parsedValue as number)) { + throw new TypeError(`${fieldName} must be a valid number`); + } + + break; + } + case 'integer': { + parsedValue = Number.parseInt(answer, 10); + if (Number.isNaN(parsedValue as number)) { + throw new TypeError(`${fieldName} must be a valid integer`); + } + + break; + } + default: { + if (field.enum) { + if (!field.enum.includes(answer)) { + throw new Error(`${fieldName} must be one of: ${field.enum.join(', ')}`); + } + parsedValue = answer; + } else { + parsedValue = answer; + } + } + } + + content[fieldName] = parsedValue as string | number | boolean | string[]; + } + } catch (error) { + console.log(`❌ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return returnResult({ action: 'cancel' }); + } + + // If we didn't complete all fields due to an error, try again + if ( + Object.keys(content).length !== + Object.keys(properties).filter(name => required.includes(name) || content[name] !== undefined).length + ) { + if (attempts < maxAttempts) { + console.log('Please try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return returnResult({ action: 'decline' }); + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('❌ Validation errors:'); + if (validate.errors) + for (const error of validate.errors) { + console.log(` - ${error.instancePath || 'root'}: ${error.message}`); + } + + if (attempts < maxAttempts) { + console.log('Please correct the errors and try again...'); + continue; + } else { + console.log('Maximum attempts reached. Declining request.'); + return returnResult({ action: 'decline' }); + } + } + + // Show the collected data and ask for confirmation + console.log('\n✅ Collected data:'); + console.log(JSON.stringify(content, null, 2)); + + const confirmAnswer = await new Promise(resolve => { + readline.question('\nSubmit this information? (yes/no/cancel): ', input => { + resolve(input.trim().toLowerCase()); + }); + }); + + switch (confirmAnswer) { + case 'yes': + case 'y': { + return returnResult({ + action: 'accept', + content + }); + } + case 'cancel': + case 'c': { + return returnResult({ action: 'cancel' }); + } + case 'no': + case 'n': { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return returnResult({ action: 'decline' }); + } + + break; + } + // No default + } + } + + console.log('Maximum attempts reached. Declining request.'); + return returnResult({ action: 'decline' }); + }); + + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + sessionId: sessionId + }); + + // Set up notification handlers + client.setNotificationHandler('notifications/message', notification => { + notificationCount++; + console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); + // Re-display the prompt + process.stdout.write('> '); + }); + + client.setNotificationHandler('notifications/resources/list_changed', async _ => { + console.log(`\nResource list changed notification received!`); + try { + if (!client) { + console.log('Client disconnected, cannot fetch resources'); + return; + } + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }); + console.log('Available resources count:', resourcesResult.resources.length); + } catch { + console.log('Failed to list resources after change notification'); + } + // Re-display the prompt + process.stdout.write('> '); + }); + + // Connect the client + await client.connect(transport); + sessionId = transport.sessionId; + console.log('Transport created with session ID:', sessionId); + console.log('Connected to MCP server'); + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function terminateSession(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + console.log('Terminating session with ID:', transport.sessionId); + await transport.terminateSession(); + console.log('Session terminated successfully'); + + // Check if sessionId was cleared after termination + if (transport.sessionId) { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } else { + console.log('Session ID has been cleared'); + sessionId = undefined; + + // Also close the transport and clear client objects + await transport.close(); + console.log('Transport closed after session termination'); + client = null; + transport = null; + } + } catch (error) { + console.error('Error terminating session:', error); + } +} + +async function reconnect(): Promise { + if (client) { + await disconnect(); + } + await connect(); +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - id: ${tool.name}, name: ${getDisplayName(tool)}, description: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server (${error})`); + } +} + +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.callTool({ name, arguments: args }); + + console.log('Tool result:'); + const resourceLinks: ResourceLink[] = []; + + for (const item of result.content) { + switch (item.type) { + case 'text': { + console.log(` ${item.text}`); + + break; + } + case 'resource_link': { + const resourceLink = item as ResourceLink; + resourceLinks.push(resourceLink); + console.log(` 📁 Resource Link: ${resourceLink.name}`); + console.log(` URI: ${resourceLink.uri}`); + if (resourceLink.mimeType) { + console.log(` Type: ${resourceLink.mimeType}`); + } + if (resourceLink.description) { + console.log(` Description: ${resourceLink.description}`); + } + + break; + } + case 'resource': { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + + break; + } + case 'image': { + console.log(` [Image: ${item.mimeType}]`); + + break; + } + case 'audio': { + console.log(` [Audio: ${item.mimeType}]`); + + break; + } + default: { + console.log(` [Unknown content type]:`, item); + } + } + } + + // Offer to read resource links + if (resourceLinks.length > 0) { + console.log(`\nFound ${resourceLinks.length} resource link(s). Use 'read-resource ' to read their content.`); + } + } catch (error) { + console.log(`Error calling tool ${name}: ${error}`); + } +} + +async function callGreetTool(name: string): Promise { + await callTool('greet', { name }); +} + +async function callMultiGreetTool(name: string): Promise { + console.log('Calling multi-greet tool with notifications...'); + await callTool('multi-greet', { name }); +} + +async function callCollectInfoTool(infoType: string): Promise { + console.log(`Testing form elicitation with collect-user-info tool (${infoType})...`); + await callTool('collect-user-info', { infoType }); +} + +async function callCollectInfoWithTask(infoType: string): Promise { + console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`); + console.log('This will create a task on the server, which will elicit input and create a task on the client.\n'); + await callToolTask('collect-user-info-task', { infoType }); +} + +async function startNotifications(interval: number, count: number): Promise { + console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`); + await callTool('start-notification-stream', { interval, count }); +} + +async function runNotificationsToolWithResumability(interval: number, count: number): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Starting notification stream with resumability: interval=${interval}ms, count=${count || 'unlimited'}`); + console.log(`Using resumption token: ${notificationsToolLastEventId || 'none'}`); + + const onLastEventIdUpdate = (event: string) => { + notificationsToolLastEventId = event; + console.log(`Updated resumption token: ${event}`); + }; + + const result = await client.callTool( + { name: 'start-notification-stream', arguments: { interval, count } }, + { + resumptionToken: notificationsToolLastEventId, + onresumptiontoken: onLastEventIdUpdate + } + ); + + console.log('Tool result:'); + for (const item of result.content) { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + } + } catch (error) { + console.log(`Error starting notification stream: ${error}`); + } +} + +async function listPrompts(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptsRequest: ListPromptsRequest = { + method: 'prompts/list', + params: {} + }; + const promptsResult = await client.request(promptsRequest); + console.log('Available prompts:'); + if (promptsResult.prompts.length === 0) { + console.log(' No prompts available'); + } else { + for (const prompt of promptsResult.prompts) { + console.log(` - id: ${prompt.name}, name: ${getDisplayName(prompt)}, description: ${prompt.description}`); + } + } + } catch (error) { + console.log(`Prompts not supported by this server (${error})`); + } +} + +async function getPrompt(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const promptRequest: GetPromptRequest = { + method: 'prompts/get', + params: { + name, + arguments: args as Record + } + }; + + const promptResult = await client.request(promptRequest); + console.log('Prompt template:'); + for (const [index, msg] of promptResult.messages.entries()) { + console.log(` [${index + 1}] ${msg.role}: ${msg.content.type === 'text' ? msg.content.text : JSON.stringify(msg.content)}`); + } + } catch (error) { + console.log(`Error getting prompt ${name}: ${error}`); + } +} + +async function listResources(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const resourcesRequest: ListResourcesRequest = { + method: 'resources/list', + params: {} + }; + const resourcesResult = await client.request(resourcesRequest); + + console.log('Available resources:'); + if (resourcesResult.resources.length === 0) { + console.log(' No resources available'); + } else { + for (const resource of resourcesResult.resources) { + console.log(` - id: ${resource.name}, name: ${getDisplayName(resource)}, description: ${resource.uri}`); + } + } + } catch (error) { + console.log(`Resources not supported by this server (${error})`); + } +} + +async function readResource(uri: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + const request: ReadResourceRequest = { + method: 'resources/read', + params: { uri } + }; + + console.log(`Reading resource: ${uri}`); + const result = await client.request(request); + + console.log('Resource contents:'); + for (const content of result.contents) { + console.log(` URI: ${content.uri}`); + if (content.mimeType) { + console.log(` Type: ${content.mimeType}`); + } + + if ('text' in content && typeof content.text === 'string') { + console.log(' Content:'); + console.log(' ---'); + console.log( + content.text + .split('\n') + .map((line: string) => ' ' + line) + .join('\n') + ); + console.log(' ---'); + } else if ('blob' in content && typeof content.blob === 'string') { + console.log(` [Binary data: ${content.blob.length} bytes]`); + } + } + } catch (error) { + console.log(`Error reading resource ${uri}: ${error}`); + } +} + +async function callToolTask(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + console.log(`Calling tool '${name}' with task-based execution...`); + console.log('Arguments:', args); + + // Use task-based execution - call now, fetch later + // Using the experimental tasks API - WARNING: may change without notice + console.log('This will return immediately while processing continues in the background...'); + + try { + // Call the tool with task metadata using streaming API + const stream = client.experimental.tasks.callToolStream( + { + name, + arguments: args + }, + { + task: { + ttl: 60_000 // Keep results for 60 seconds + } + } + ); + + console.log('Waiting for task completion...'); + + let lastStatus = ''; + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log('Task created successfully with ID:', message.task.taskId); + break; + } + case 'taskStatus': { + if (lastStatus !== message.task.status) { + console.log(` ${message.task.status}${message.task.statusMessage ? ` - ${message.task.statusMessage}` : ''}`); + } + lastStatus = message.task.status; + break; + } + case 'result': { + console.log('Task completed!'); + console.log('Tool result:'); + const toolResult = message.result as CallToolResult; + for (const item of toolResult.content) { + if (item.type === 'text') { + console.log(` ${item.text}`); + } + } + break; + } + case 'error': { + throw message.error; + } + } + } + } catch (error) { + console.log(`Error with task-based execution: ${error}`); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + // First try to terminate the session gracefully + if (transport.sessionId) { + try { + console.log('Terminating session before exit...'); + await transport.terminateSession(); + console.log('Session terminated successfully'); + } catch (error) { + console.error('Error terminating session:', error); + } + } + + // Then close the transport + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + process.stdin.setRawMode(false); + readline.close(); + console.log('\nGoodbye!'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +} + +// Set up raw mode for keyboard input to capture Escape key +process.stdin.setRawMode(true); +process.stdin.on('data', async data => { + // Check for Escape key (27) + if (data.length === 1 && data[0] === 27) { + console.log('\nESC key pressed. Disconnecting from server...'); + + // Abort current operation and disconnect from server + if (client && transport) { + await disconnect(); + console.log('Disconnected. Press Enter to continue.'); + } else { + console.log('Not connected to server.'); + } + + // Re-display the prompt + process.stdout.write('> '); + } +}); + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client +try { + await main(); +} catch (error) { + console.error('Error running MCP client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/simpleTaskInteractiveClient.ts b/examples/client/src/simpleTaskInteractiveClient.ts new file mode 100644 index 0000000..0a35fab --- /dev/null +++ b/examples/client/src/simpleTaskInteractiveClient.ts @@ -0,0 +1,204 @@ +/** + * Simple interactive task client demonstrating elicitation and sampling responses. + * + * This client connects to simpleTaskInteractive.ts server and demonstrates: + * - Handling elicitation requests (y/n confirmation) + * - Handling sampling requests (returns a hardcoded haiku) + * - Using task-based tool execution with streaming + */ + +import { createInterface } from 'node:readline'; + +import type { CallToolResult, CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/client'; +import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +function question(prompt: string): Promise { + return new Promise(resolve => { + readline.question(prompt, answer => { + resolve(answer.trim()); + }); + }); +} + +function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string { + const textContent = result.content.find((c): c is TextContent => c.type === 'text'); + return textContent?.text ?? '(no text)'; +} + +async function elicitationCallback(params: { + mode?: string; + message: string; + requestedSchema?: object; +}): Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record }> { + console.log(`\n[Elicitation] Server asks: ${params.message}`); + + // Simple terminal prompt for y/n + const response = await question('Your response (y/n): '); + const confirmed = ['y', 'yes', 'true', '1'].includes(response.toLowerCase()); + + console.log(`[Elicitation] Responding with: confirm=${confirmed}`); + return { action: 'accept', content: { confirm: confirmed } }; +} + +async function samplingCallback(params: CreateMessageRequest['params']): Promise { + // Get the prompt from the first message + let prompt = 'unknown'; + if (params.messages && params.messages.length > 0) { + const firstMessage = params.messages[0]!; + const content = firstMessage.content; + if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) { + prompt = content.text; + } else if (Array.isArray(content)) { + const textPart = content.find(c => c.type === 'text' && 'text' in c); + if (textPart && 'text' in textPart) { + prompt = textPart.text; + } + } + } + + console.log(`\n[Sampling] Server requests LLM completion for: ${prompt}`); + + // Return a hardcoded haiku (in real use, call your LLM here) + const haiku = `Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye`; + + console.log('[Sampling] Responding with haiku'); + return { + model: 'mock-haiku-model', + role: 'assistant', + content: { type: 'text', text: haiku } + }; +} + +async function run(url: string): Promise { + console.log('Simple Task Interactive Client'); + console.log('=============================='); + console.log(`Connecting to ${url}...`); + + // Create client with elicitation and sampling capabilities + const client = new Client( + { name: 'simple-task-interactive-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { form: {} }, + sampling: {} + } + } + ); + + // Set up elicitation request handler + client.setRequestHandler('elicitation/create', async request => { + if (request.params.mode && request.params.mode !== 'form') { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + return elicitationCallback(request.params); + }); + + // Set up sampling request handler + client.setRequestHandler('sampling/createMessage', async request => { + return samplingCallback(request.params) as unknown as ReturnType; + }); + + // Connect to server + const transport = new StreamableHTTPClientTransport(new URL(url)); + await client.connect(transport); + console.log('Connected!\n'); + + // List tools + const toolsResult = await client.listTools(); + console.log(`Available tools: ${toolsResult.tools.map(t => t.name).join(', ')}`); + + // Demo 1: Elicitation (confirm_delete) + console.log('\n--- Demo 1: Elicitation ---'); + console.log('Calling confirm_delete tool...'); + + const confirmStream = client.experimental.tasks.callToolStream( + { name: 'confirm_delete', arguments: { filename: 'important.txt' } }, + { task: { ttl: 60_000 } } + ); + + for await (const message of confirmStream) { + switch (message.type) { + case 'taskCreated': { + console.log(`Task created: ${message.task.taskId}`); + break; + } + case 'taskStatus': { + console.log(`Task status: ${message.task.status}`); + break; + } + case 'result': { + const toolResult = message.result as CallToolResult; + console.log(`Result: ${getTextContent(toolResult)}`); + break; + } + case 'error': { + console.error(`Error: ${message.error}`); + break; + } + } + } + + // Demo 2: Sampling (write_haiku) + console.log('\n--- Demo 2: Sampling ---'); + console.log('Calling write_haiku tool...'); + + const haikuStream = client.experimental.tasks.callToolStream( + { name: 'write_haiku', arguments: { topic: 'autumn leaves' } }, + { task: { ttl: 60_000 } } + ); + + for await (const message of haikuStream) { + switch (message.type) { + case 'taskCreated': { + console.log(`Task created: ${message.task.taskId}`); + break; + } + case 'taskStatus': { + console.log(`Task status: ${message.task.status}`); + break; + } + case 'result': { + const toolResult = message.result as CallToolResult; + console.log(`Result:\n${getTextContent(toolResult)}`); + break; + } + case 'error': { + console.error(`Error: ${message.error}`); + break; + } + } + } + + // Cleanup + console.log('\nDemo complete. Closing connection...'); + await transport.close(); + readline.close(); +} + +// Parse command line arguments +const args = process.argv.slice(2); +let url = 'http://localhost:8000/mcp'; + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--url' && args[i + 1]) { + url = args[i + 1]!; + i++; + } +} + +// Run the client +try { + await run(url); +} catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/simpleTokenProvider.ts b/examples/client/src/simpleTokenProvider.ts new file mode 100644 index 0000000..ce68fde --- /dev/null +++ b/examples/client/src/simpleTokenProvider.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +/** + * Example demonstrating the minimal AuthProvider for bearer token authentication. + * + * AuthProvider is the base interface for all client auth. For simple cases where + * tokens are managed externally — pre-configured API tokens, gateway/proxy patterns, + * or tokens obtained through a separate auth flow — implement only `token()`. + * + * For OAuth flows (client_credentials, private_key_jwt, etc.), use the built-in + * providers which implement both `token()` and `onUnauthorized()`. + * + * Environment variables: + * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + * MCP_TOKEN - Bearer token to use for authentication (required) + */ + +import type { AuthProvider } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +async function main() { + const token = process.env.MCP_TOKEN; + if (!token) { + console.error('MCP_TOKEN environment variable is required'); + process.exit(1); + } + + // AuthProvider with just token() — the simplest possible auth. + // token() is called before every request, so it can handle refresh internally. + // With no onUnauthorized(), a 401 throws UnauthorizedError immediately. + const authProvider: AuthProvider = { + token: async () => token + }; + + const client = new Client({ name: 'auth-provider-example', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider }); + + await client.connect(transport); + console.log('Connected successfully.'); + + const tools = await client.listTools(); + console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); + + await transport.close(); +} + +try { + await main(); +} catch (error) { + console.error('Error running client:', error); + process.exitCode = 1; +} diff --git a/examples/client/src/ssePollingClient.ts b/examples/client/src/ssePollingClient.ts new file mode 100644 index 0000000..4887471 --- /dev/null +++ b/examples/client/src/ssePollingClient.ts @@ -0,0 +1,109 @@ +/** + * SSE Polling Example Client (SEP-1699) + * + * This example demonstrates client-side behavior during server-initiated + * SSE stream disconnection and automatic reconnection. + * + * Key features demonstrated: + * - Automatic reconnection when server closes SSE stream + * - Event replay via Last-Event-ID header + * - Resumption token tracking via onresumptiontoken callback + * + * Run with: pnpm tsx src/ssePollingClient.ts + * Requires: ssePollingExample.ts server running on port 3001 + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const SERVER_URL = 'http://localhost:3001/mcp'; + +async function main(): Promise { + console.log('SSE Polling Example Client'); + console.log('=========================='); + console.log(`Connecting to ${SERVER_URL}...`); + console.log(''); + + // Create transport with reconnection options + const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { + // Use default reconnection options - SDK handles automatic reconnection + }); + + // Track the last event ID for debugging + let lastEventId: string | undefined; + + // Set up transport error handler to observe disconnections + // Filter out expected errors from SSE reconnection + transport.onerror = error => { + // Skip abort errors during intentional close + if (error.message.includes('AbortError')) return; + // Show SSE disconnect (expected when server closes stream) + if (error.message.includes('Unexpected end of JSON')) { + console.log('[Transport] SSE stream disconnected - client will auto-reconnect'); + return; + } + console.log(`[Transport] Error: ${error.message}`); + }; + + // Set up transport close handler + transport.onclose = () => { + console.log('[Transport] Connection closed'); + }; + + // Create and connect client + const client = new Client({ + name: 'sse-polling-client', + version: '1.0.0' + }); + + // Set up notification handler to receive progress updates + client.setNotificationHandler('notifications/message', notification => { + const data = notification.params.data; + console.log(`[Notification] ${data}`); + }); + + try { + await client.connect(transport); + console.log('[Client] Connected successfully'); + console.log(''); + + // Call the long-task tool + console.log('[Client] Calling long-task tool...'); + console.log('[Client] Server will disconnect mid-task to demonstrate polling'); + console.log(''); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'long-task', + arguments: {} + } + }, + { + // Track resumption tokens for debugging + onresumptiontoken: token => { + lastEventId = token; + console.log(`[Event ID] ${token}`); + } + } + ); + + console.log(''); + console.log('[Client] Tool completed!'); + console.log(`[Result] ${JSON.stringify(result.content, null, 2)}`); + console.log(''); + console.log(`[Debug] Final event ID: ${lastEventId}`); + } catch (error) { + console.error('[Error]', error); + } finally { + await transport.close(); + console.log('[Client] Disconnected'); + } +} + +try { + await main(); +} catch (error) { + console.error('Error running MCP client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/src/streamableHttpWithSseFallbackClient.ts b/examples/client/src/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 0000000..0925f8d --- /dev/null +++ b/examples/client/src/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,181 @@ +import type { ListToolsRequest } from '@modelcontextprotocol/client'; +import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +/** + * Simplified Backwards Compatible MCP Client + * + * This client demonstrates backward compatibility with both: + * 1. Modern servers using Streamable HTTP transport (protocol version 2025-03-26) + * 2. Older servers using HTTP+SSE transport (protocol version 2024-11-05) + * + * Following the MCP specification for backwards compatibility: + * - Attempts to POST an initialize request to the server URL first (modern transport) + * - If that fails with 4xx status, falls back to GET request for SSE stream (older transport) + */ + +// Command line args processing +const args = process.argv.slice(2); +const serverUrl = args[0] || 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Backwards Compatible Client'); + console.log('==============================='); + console.log(`Connecting to server at: ${serverUrl}`); + + let client: Client; + let transport: StreamableHTTPClientTransport | SSEClientTransport; + + try { + // Try connecting with automatic transport detection + const connection = await connectWithBackwardsCompatibility(serverUrl); + client = connection.client; + transport = connection.transport; + + // Set up notification handler + client.setNotificationHandler('notifications/message', notification => { + console.log(`Notification: ${notification.params.level} - ${notification.params.data}`); + }); + + // DEMO WORKFLOW: + // 1. List available tools + console.log('\n=== Listing Available Tools ==='); + await listTools(client); + + // 2. Call the notification tool + console.log('\n=== Starting Notification Stream ==='); + await startNotificationTool(client); + + // 3. Wait for all notifications (5 seconds) + console.log('\n=== Waiting for all notifications ==='); + await new Promise(resolve => setTimeout(resolve, 5000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + } catch (error) { + console.error('Error running client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } +} + +/** + * Connect to an MCP server with backwards compatibility + * Following the spec for client backward compatibility + */ +async function connectWithBackwardsCompatibility(url: string): Promise<{ + client: Client; + transport: StreamableHTTPClientTransport | SSEClientTransport; + transportType: 'streamable-http' | 'sse'; +}> { + console.log('1. Trying Streamable HTTP transport first...'); + + // Step 1: Try Streamable HTTP transport first + const client = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + + client.onerror = error => { + console.error('Client error:', error); + }; + const baseUrl = new URL(url); + + try { + // Create modern transport + const streamableTransport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(streamableTransport); + + console.log('Successfully connected using modern Streamable HTTP transport.'); + return { + client, + transport: streamableTransport, + transportType: 'streamable-http' + }; + } catch (error) { + // Step 2: If transport fails, try the older SSE transport + console.log(`StreamableHttp transport connection failed: ${error}`); + console.log('2. Falling back to deprecated HTTP+SSE transport...'); + + try { + // Create SSE transport pointing to /sse endpoint + const sseTransport = new SSEClientTransport(baseUrl); + const sseClient = new Client({ + name: 'backwards-compatible-client', + version: '1.0.0' + }); + await sseClient.connect(sseTransport); + + console.log('Successfully connected using deprecated HTTP+SSE transport.'); + return { + client: sseClient, + transport: sseTransport, + transportType: 'sse' + }; + } catch (sseError) { + console.error(`Failed to connect with either transport method:\n1. Streamable HTTP error: ${error}\n2. SSE error: ${sseError}`); + throw new Error('Could not connect to server with any available transport'); + } + } +} + +/** + * List available tools on the server + */ +async function listTools(client: Client): Promise { + try { + const toolsRequest: ListToolsRequest = { + method: 'tools/list', + params: {} + }; + const toolsResult = await client.request(toolsRequest); + + console.log('Available tools:'); + if (toolsResult.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of toolsResult.tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } + } + } catch (error) { + console.log(`Tools not supported by this server: ${error}`); + } +} + +/** + * Start a notification stream by calling the notification tool + */ +async function startNotificationTool(client: Client): Promise { + try { + console.log('Calling notification tool...'); + const result = await client.callTool({ + name: 'start-notification-stream', + arguments: { + interval: 1000, // 1 second between notifications + count: 5 // Send 5 notifications + } + }); + + console.log('Tool result:'); + for (const item of result.content) { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` ${item.type} content:`, item); + } + } + } catch (error) { + console.log(`Error calling notification tool: ${error}`); + } +} + +// Start the client +try { + await main(); +} catch (error) { + console.error('Error running MCP client:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/client/tsconfig.json b/examples/client/tsconfig.json new file mode 100644 index 0000000..5c1f7fc --- /dev/null +++ b/examples/client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ], + "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"] + } + } +} diff --git a/examples/client/tsdown.config.ts b/examples/client/tsdown.config.ts new file mode 100644 index 0000000..efc4299 --- /dev/null +++ b/examples/client/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + // 1. Entry Points + // Directly matches package.json include/exclude globs + entry: ['src/**/*.ts'], + + // 2. Output Configuration + format: ['esm'], + outDir: 'dist', + clean: true, // Recommended: Cleans 'dist' before building + sourcemap: true, + + // 3. Platform & Target + target: 'esnext', + platform: 'node', + shims: true, // Polyfills common Node.js shims (__dirname, etc.) + + // 4. Type Definitions + // Bundles d.ts files into a single output + dts: false, + // 5. Vendoring Strategy - Bundle the code for this specific package into the output, + // but treat all other dependencies as external (require/import). + noExternal: ['@modelcontextprotocol/examples-shared'] +}); diff --git a/examples/client/vitest.config.js b/examples/client/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/examples/client/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/examples/server-quickstart/.gitignore b/examples/server-quickstart/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/examples/server-quickstart/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/examples/server-quickstart/package.json b/examples/server-quickstart/package.json new file mode 100644 index 0000000..133af7a --- /dev/null +++ b/examples/server-quickstart/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modelcontextprotocol/examples-server-quickstart", + "private": true, + "version": "2.0.0-alpha.0", + "type": "module", + "bin": { + "weather": "./build/index.js" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/server": "workspace:^", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "catalog:devTools" + } +} diff --git a/examples/server-quickstart/src/index.ts b/examples/server-quickstart/src/index.ts new file mode 100644 index 0000000..22d4591 --- /dev/null +++ b/examples/server-quickstart/src/index.ts @@ -0,0 +1,222 @@ +//#region prelude +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const NWS_API_BASE = 'https://api.weather.gov'; +const USER_AGENT = 'weather-app/1.0'; + +// Create server instance +const server = new McpServer({ + name: 'weather', + version: '1.0.0', +}); +//#endregion prelude + +//#region helpers +// Helper function for making NWS API requests +async function makeNWSRequest(url: string): Promise { + const headers = { + 'User-Agent': USER_AGENT, + Accept: 'application/geo+json', + }; + + try { + const response = await fetch(url, { headers }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return (await response.json()) as T; + } catch (error) { + console.error('Error making NWS request:', error); + return null; + } +} + +interface AlertFeature { + properties: { + event?: string; + areaDesc?: string; + severity?: string; + status?: string; + headline?: string; + }; +} + +// Format alert data +function formatAlert(feature: AlertFeature): string { + const props = feature.properties; + return [ + `Event: ${props.event || 'Unknown'}`, + `Area: ${props.areaDesc || 'Unknown'}`, + `Severity: ${props.severity || 'Unknown'}`, + `Status: ${props.status || 'Unknown'}`, + `Headline: ${props.headline || 'No headline'}`, + '---', + ].join('\n'); +} + +interface ForecastPeriod { + name?: string; + temperature?: number; + temperatureUnit?: string; + windSpeed?: string; + windDirection?: string; + shortForecast?: string; +} + +interface AlertsResponse { + features: AlertFeature[]; +} + +interface PointsResponse { + properties: { + forecast?: string; + }; +} + +interface ForecastResponse { + properties: { + periods: ForecastPeriod[]; + }; +} +//#endregion helpers + +//#region registerTools +// Register weather tools +server.registerTool( + 'get-alerts', + { + title: 'Get Weather Alerts', + description: 'Get weather alerts for a state', + inputSchema: z.object({ + state: z.string().length(2) + .describe('Two-letter state code (e.g. CA, NY)'), + }), + }, + async ({ state }) => { + const stateCode = state.toUpperCase(); + const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`; + const alertsData = await makeNWSRequest(alertsUrl); + + if (!alertsData) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to retrieve alerts data', + }], + }; + } + + const features = alertsData.features || []; + + if (features.length === 0) { + return { + content: [{ + type: 'text' as const, + text: `No active alerts for ${stateCode}`, + }], + }; + } + + const formattedAlerts = features.map(formatAlert); + + return { + content: [{ + type: 'text' as const, + text: `Active alerts for ${stateCode}:\n\n${formattedAlerts.join('\n')}`, + }], + }; + }, +); + +server.registerTool( + 'get-forecast', + { + title: 'Get Weather Forecast', + description: 'Get weather forecast for a location', + inputSchema: z.object({ + latitude: z.number().min(-90).max(90) + .describe('Latitude of the location'), + longitude: z.number().min(-180).max(180) + .describe('Longitude of the location'), + }), + }, + async ({ latitude, longitude }) => { + // Get grid point data + const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(4)},${longitude.toFixed(4)}`; + const pointsData = await makeNWSRequest(pointsUrl); + + if (!pointsData) { + return { + content: [{ + type: 'text' as const, + text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`, + }], + }; + } + + const forecastUrl = pointsData.properties?.forecast; + if (!forecastUrl) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to get forecast URL from grid point data', + }], + }; + } + + // Get forecast data + const forecastData = await makeNWSRequest(forecastUrl); + if (!forecastData) { + return { + content: [{ + type: 'text' as const, + text: 'Failed to retrieve forecast data', + }], + }; + } + + const periods = forecastData.properties?.periods || []; + if (periods.length === 0) { + return { + content: [{ + type: 'text' as const, + text: 'No forecast periods available', + }], + }; + } + + // Format forecast periods + const formattedForecast = periods.map((period: ForecastPeriod) => + [ + `${period.name || 'Unknown'}:`, + `Temperature: ${period.temperature || 'Unknown'}°${period.temperatureUnit || 'F'}`, + `Wind: ${period.windSpeed || 'Unknown'} ${period.windDirection || ''}`, + `${period.shortForecast || 'No forecast available'}`, + '---', + ].join('\n'), + ); + + return { + content: [{ + type: 'text' as const, + text: `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join('\n')}`, + }], + }; + }, +); +//#endregion registerTools + +//#region main +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Weather MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error in main():', error); + process.exit(1); +}); +//#endregion main diff --git a/examples/server-quickstart/tsconfig.json b/examples/server-quickstart/tsconfig.json new file mode 100644 index 0000000..c760b5e --- /dev/null +++ b/examples/server-quickstart/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/examples/server/README.md b/examples/server/README.md new file mode 100644 index 0000000..0f684be --- /dev/null +++ b/examples/server/README.md @@ -0,0 +1,173 @@ +# MCP TypeScript SDK Examples (Server) + +This directory contains runnable MCP **server** examples built with `@modelcontextprotocol/server` plus framework adapters: + +- `@modelcontextprotocol/express` +- `@modelcontextprotocol/hono` + +For client examples, see [`../client/README.md`](../client/README.md). For guided docs, see [`../../docs/server.md`](../../docs/server.md). + +## Running examples + +From anywhere in the SDK: + +```bash +pnpm install +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +``` + +Or, from within this package: + +```bash +cd examples/server +pnpm tsx src/simpleStreamableHttp.ts +``` + +## Example index + +| Scenario | Description | File | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) | +| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) | +| Resource-Server-only auth | Minimal OAuth RS using SDK's `mcpAuthMetadataRouter` + `requireBearerAuth` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) | +| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) | +| Server notifications over Streamable HTTP | Demonstrates server-initiated notifications via GET+SSE. | [`src/standaloneSseWithGetStreamableHttp.ts`](src/standaloneSseWithGetStreamableHttp.ts) | +| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) | +| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) | +| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | +| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) | +| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) | +| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | +| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | + +## OAuth demo flags (Streamable HTTP server) + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts --oauth +``` + +## URL elicitation example (server + client) + +Run the server: + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/elicitationUrlExample.ts +``` + +Run the client in another terminal: + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/elicitationUrlExample.ts +``` + +## Multi-node deployment patterns + +When deploying MCP servers in a horizontally scaled environment (multiple server instances), there are a few different options that can be useful for different use cases: + +- **Stateless mode** - no need to maintain state between calls. +- **Persistent storage mode** - state stored in a database; any node can handle a session. +- **Local state with message routing** - stateful nodes + pub/sub routing for a session. + +### Stateless mode + +To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +### Persistent storage mode + +Configure the transport with session management, but use an external event store: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ Database (PostgreSQL) │ +│ │ +│ • Session state │ +│ • Event storage for resumability │ +└─────────────────────────────────────────────┘ +``` + +### Streamable HTTP with distributed message routing + +For scenarios where local in-memory state must be maintained on specific nodes, combine Streamable HTTP with pub/sub routing so one node can terminate the client connection while another node owns the session state. + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │◄───►│ MCP Server #2 │ +│ (Has Session A) │ │ (Has Session B) │ +└─────────────────┘ └─────────────────────┘ + ▲│ ▲│ + │▼ │▼ +┌─────────────────────────────────────────────┐ +│ Message Queue / Pub-Sub │ +│ │ +│ • Session ownership registry │ +│ • Bidirectional message routing │ +│ • Request/response forwarding │ +└─────────────────────────────────────────────┘ +``` + +## Backwards compatibility (Streamable HTTP ↔ legacy SSE) + +Start the server: + +```bash +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleStreamableHttp.ts +``` + +Then run the backwards-compatible client: + +```bash +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/streamableHttpWithSseFallbackClient.ts +``` diff --git a/examples/server/eslint.config.mjs b/examples/server/eslint.config.mjs new file mode 100644 index 0000000..83b7987 --- /dev/null +++ b/examples/server/eslint.config.mjs @@ -0,0 +1,14 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Allow console statements in examples only + 'no-console': 'off' + } + } +]; diff --git a/examples/server/package.json b/examples/server/package.json new file mode 100644 index 0000000..fcff95d --- /dev/null +++ b/examples/server/package.json @@ -0,0 +1,58 @@ +{ + "name": "@modelcontextprotocol/examples-server", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build:esm && pnpm run build:cjs", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "start": "pnpm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly", + "@modelcontextprotocol/examples-shared": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", + "@modelcontextprotocol/hono": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", + "better-auth": "^1.4.17", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "hono": "catalog:runtimeServerOnly", + "valibot": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "tsdown": "catalog:devTools" + } +} diff --git a/examples/server/src/README-simpleTaskInteractive.md b/examples/server/src/README-simpleTaskInteractive.md new file mode 100644 index 0000000..5e9793d --- /dev/null +++ b/examples/server/src/README-simpleTaskInteractive.md @@ -0,0 +1,181 @@ +# Simple Task Interactive Example + +This example demonstrates the MCP Tasks message queue pattern with interactive server-to-client requests (elicitation and sampling). + +## Overview + +The example consists of two components: + +1. **Server** (`simpleTaskInteractive.ts`) - Exposes two task-based tools that require client interaction: + - `confirm_delete` - Uses elicitation to ask the user for confirmation before "deleting" a file + - `write_haiku` - Uses sampling to request an LLM to generate a haiku on a topic + +2. **Client** (`simpleTaskInteractiveClient.ts`) - Connects to the server and handles: + - Elicitation requests with simple y/n terminal prompts + - Sampling requests with a mock haiku generator + +## Key Concepts + +### Task-Based Execution + +Both tools use `execution.taskSupport: 'required'`, meaning they follow the "call-now, fetch-later" pattern: + +1. Client calls tool with `task: { ttl: 60000 }` parameter +2. Server creates a task and returns `CreateTaskResult` immediately +3. Client polls via `tasks/result` to get the final result +4. Server sends elicitation/sampling requests through the task message queue +5. Client handles requests and returns responses +6. Server completes the task with the final result + +### Message Queue Pattern + +When a tool needs to interact with the client (elicitation or sampling), it: + +1. Updates task status to `input_required` +2. Enqueues the request in the task message queue +3. Waits for the response via a Resolver +4. Updates task status back to `working` +5. Continues processing + +The `TaskResultHandler` dequeues messages when the client calls `tasks/result` and routes responses back to waiting Resolvers. + +## Running the Example + +### Start the Server + +```bash +# From anywhere in the SDK +pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts + +# Or with a custom port +PORT=9000 pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts +``` + +Or, from within the `examples/server` package: + +```bash +cd examples/server +pnpm tsx src/simpleTaskInteractive.ts + +# Or with a custom port +PORT=9000 pnpm tsx src/simpleTaskInteractive.ts +``` + +The server will start on http://localhost:8000/mcp (or your custom port). + +### Run the Client + +```bash +# From anywhere in the SDK +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts + +# Or connect to a different server +pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp +``` + +Or, from within the `examples/client` package: + +```bash +cd examples/client +pnpm tsx src/simpleTaskInteractiveClient.ts + +# Or connect to a different server +pnpm tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp +``` + +## Expected Output + +### Server Output + +``` +Starting server on http://localhost:8000/mcp + +Available tools: + - confirm_delete: Demonstrates elicitation (asks user y/n) + - write_haiku: Demonstrates sampling (requests LLM completion) + +[Server] confirm_delete called, task created: task-abc123 +[Server] confirm_delete: asking about 'important.txt' +[Server] Sending elicitation request to client... +[Server] tasks/result called for task task-abc123 +[Server] Delivering queued request message for task task-abc123 +[Server] Received elicitation response: action=accept, content={"confirm":true} +[Server] Completing task with result: Deleted 'important.txt' + +[Server] write_haiku called, task created: task-def456 +[Server] write_haiku: topic 'autumn leaves' +[Server] Sending sampling request to client... +[Server] tasks/result called for task task-def456 +[Server] Delivering queued request message for task task-def456 +[Server] Received sampling response: Cherry blossoms fall... +[Server] Completing task with haiku +``` + +### Client Output + +``` +Simple Task Interactive Client +============================== +Connecting to http://localhost:8000/mcp... +Connected! + +Available tools: confirm_delete, write_haiku + +--- Demo 1: Elicitation --- +Calling confirm_delete tool... +Task created: task-abc123 +Task status: working + +[Elicitation] Server asks: Are you sure you want to delete 'important.txt'? +Your response (y/n): y +[Elicitation] Responding with: confirm=true +Task status: input_required +Task status: completed +Result: Deleted 'important.txt' + +--- Demo 2: Sampling --- +Calling write_haiku tool... +Task created: task-def456 +Task status: working + +[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves +[Sampling] Responding with haiku +Task status: input_required +Task status: completed +Result: +Haiku: +Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye + +Demo complete. Closing connection... +``` + +## Implementation Details + +### Server Components + +- **Resolver**: Promise-like class for passing results between async operations +- **TaskMessageQueueWithResolvers**: Extended message queue that tracks pending requests with their Resolvers +- **TaskStoreWithNotifications**: Extended task store with notification support for status changes +- **TaskResultHandler**: Handles `tasks/result` requests by dequeuing messages and routing responses +- **TaskSession**: Wraps the server to enqueue requests during task execution + +### Client Capabilities + +The client declares these capabilities during initialization: + +```typescript +capabilities: { + elicitation: { form: {} }, + sampling: {} +} +``` + +This tells the server that the client can handle both form-based elicitation and sampling requests. + +## Related Files + +- `packages/core/src/experimental/tasks/interfaces.ts` - Core task interfaces (TaskStore, TaskMessageQueue) +- `packages/core/src/experimental/tasks/stores/in-memory.ts` - In-memory task store implementation +- `packages/core/src/types/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.) diff --git a/examples/server/src/arktypeExample.ts b/examples/server/src/arktypeExample.ts new file mode 100644 index 0000000..4a47053 --- /dev/null +++ b/examples/server/src/arktypeExample.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Minimal MCP server using ArkType for schema validation. + * ArkType implements the Standard Schema spec with built-in JSON Schema conversion. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { type } from 'arktype'; + +const server = new McpServer({ + name: 'arktype-example', + version: '1.0.0' +}); + +// Register a tool with ArkType schema +server.registerTool( + 'greet', + { + description: 'Generate a greeting', + inputSchema: type({ name: 'string' }) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/server/src/customMethodExample.ts b/examples/server/src/customMethodExample.ts new file mode 100644 index 0000000..6968a26 --- /dev/null +++ b/examples/server/src/customMethodExample.ts @@ -0,0 +1,23 @@ +/** + * Custom (non-spec) method example: a server that handles a vendor-prefixed + * `acme/search` request and emits `acme/searchProgress` notifications. + * + * Spawned via stdio by `examples/client/src/customMethodExample.ts`; do not run standalone. + */ +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { z } from 'zod/v4'; + +const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); +const SearchResult = z.object({ items: z.array(z.string()) }); + +const mcp = new McpServer({ name: 'acme-search', version: '0.0.0' }); + +mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); + const items = Array.from({ length: params.limit }, (_, i) => `${params.query}-${i}`); + await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); + return { items }; +}); + +await mcp.connect(new StdioServerTransport()); diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts new file mode 100644 index 0000000..c580432 --- /dev/null +++ b/examples/server/src/customProtocolVersion.ts @@ -0,0 +1,65 @@ +/** + * Example: Custom Protocol Version Support + * + * This demonstrates how to support protocol versions not yet in the SDK. + * First version in the list is used as fallback when client requests + * an unsupported version. + * + * Run with: pnpm tsx src/customProtocolVersion.ts + */ + +import { randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; + +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; + +// Add support for a newer protocol version (first in list is fallback) +const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; + +const server = new McpServer( + { name: 'custom-protocol-server', version: '1.0.0' }, + { + supportedProtocolVersions: CUSTOM_VERSIONS, + capabilities: { tools: {} } + } +); + +// Register a tool that shows the protocol configuration +server.registerTool( + 'get-protocol-info', + { + title: 'Protocol Info', + description: 'Returns protocol version configuration' + }, + async (): Promise => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }, null, 2) + } + ] + }) +); + +// Create transport - server passes versions automatically during connect() +const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() +}); + +await server.connect(transport); + +// Simple HTTP server +const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; + +createServer(async (req, res) => { + if (req.url === '/mcp') { + await transport.handleRequest(req, res); + } else { + res.writeHead(404).end('Not Found'); + } +}).listen(PORT, () => { + console.log(`MCP server with custom protocol versions on port ${PORT}`); + console.log(`Supported versions: ${CUSTOM_VERSIONS.join(', ')}`); +}); diff --git a/examples/server/src/elicitationFormExample.ts b/examples/server/src/elicitationFormExample.ts new file mode 100644 index 0000000..e059e84 --- /dev/null +++ b/examples/server/src/elicitationFormExample.ts @@ -0,0 +1,488 @@ +// Run with: pnpm tsx src/elicitationFormExample.ts +// +// This example demonstrates how to use form elicitation to collect structured user input +// with JSON Schema validation via a local HTTP server with SSE streaming. +// Form elicitation allows servers to request *non-sensitive* user input through the client +// with schema-based validation. +// Note: See also elicitationUrlExample.ts for an example of using URL elicitation +// to collect *sensitive* user input via a browser. + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; + +// Create a fresh MCP server per client connection to avoid shared state between clients. +// The validator supports format validation (email, date, etc.) if ajv-formats is installed. +const getServer = () => { + const mcpServer = new McpServer( + { + name: 'form-elicitation-example-server', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + /** + * Example 1: Simple user registration tool + * Collects username, email, and password from the user + */ + mcpServer.registerTool( + 'register_user', + { + description: 'Register a new user account by collecting their information' + }, + async () => { + try { + // Request user information through form elicitation + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your registration information:', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your desired username (3-20 characters)', + minLength: 3, + maxLength: 20 + }, + email: { + type: 'string', + title: 'Email', + description: 'Your email address', + format: 'email' + }, + password: { + type: 'string', + title: 'Password', + description: 'Your password (min 8 characters)', + minLength: 8 + }, + newsletter: { + type: 'boolean', + title: 'Newsletter', + description: 'Subscribe to newsletter?', + default: false + } + }, + required: ['username', 'email', 'password'] + } + }); + + // Handle the different possible actions + if (result.action === 'accept' && result.content) { + const { username, email, newsletter } = result.content as { + username: string; + email: string; + password: string; + newsletter?: boolean; + }; + + return { + content: [ + { + type: 'text', + text: `Registration successful!\n\nUsername: ${username}\nEmail: ${email}\nNewsletter: ${newsletter ? 'Yes' : 'No'}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: 'Registration cancelled by user.' + } + ] + }; + } else { + return { + content: [ + { + type: 'text', + text: 'Registration was cancelled.' + } + ] + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Registration failed: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + } + ); + + /** + * Example 2: Multi-step workflow with multiple form elicitation requests + * Demonstrates how to collect information in multiple steps + */ + mcpServer.registerTool( + 'create_event', + { + description: 'Create a calendar event by collecting event details' + }, + async () => { + try { + // Step 1: Collect basic event information + const basicInfo = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 1: Enter basic event information', + requestedSchema: { + type: 'object', + properties: { + title: { + type: 'string', + title: 'Event Title', + description: 'Name of the event', + minLength: 1 + }, + description: { + type: 'string', + title: 'Description', + description: 'Event description (optional)' + } + }, + required: ['title'] + } + }); + + if (basicInfo.action !== 'accept' || !basicInfo.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Step 2: Collect date and time + const dateTime = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Step 2: Enter date and time', + requestedSchema: { + type: 'object', + properties: { + date: { + type: 'string', + title: 'Date', + description: 'Event date', + format: 'date' + }, + startTime: { + type: 'string', + title: 'Start Time', + description: 'Event start time (HH:MM)' + }, + duration: { + type: 'integer', + title: 'Duration', + description: 'Duration in minutes', + minimum: 15, + maximum: 480 + } + }, + required: ['date', 'startTime', 'duration'] + } + }); + + if (dateTime.action !== 'accept' || !dateTime.content) { + return { + content: [{ type: 'text', text: 'Event creation cancelled.' }] + }; + } + + // Combine all collected information + const event = { + ...basicInfo.content, + ...dateTime.content + }; + + return { + content: [ + { + type: 'text', + text: `Event created successfully!\n\n${JSON.stringify(event, null, 2)}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Event creation failed: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + } + ); + + /** + * Example 3: Collecting address information + * Demonstrates validation with patterns and optional fields + */ + mcpServer.registerTool( + 'update_shipping_address', + { + description: 'Update shipping address with validation' + }, + async () => { + try { + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: 'Please provide your shipping address:', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Recipient name', + minLength: 1 + }, + street: { + type: 'string', + title: 'Street Address', + minLength: 1 + }, + city: { + type: 'string', + title: 'City', + minLength: 1 + }, + state: { + type: 'string', + title: 'State/Province', + minLength: 2, + maxLength: 2 + }, + zipCode: { + type: 'string', + title: 'ZIP/Postal Code', + description: '5-digit ZIP code' + }, + phone: { + type: 'string', + title: 'Phone Number (optional)', + description: 'Contact phone number' + } + }, + required: ['name', 'street', 'city', 'state', 'zipCode'] + } + }); + + if (result.action === 'accept' && result.content) { + return { + content: [ + { + type: 'text', + text: `Address updated successfully!\n\n${JSON.stringify(result.content, null, 2)}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [{ type: 'text', text: 'Address update cancelled by user.' }] + }; + } else { + return { + content: [{ type: 'text', text: 'Address update was cancelled.' }] + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Address update failed: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + } + ); + + return mcpServer; +}; + +async function main() { + const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000; + + const app = createMcpExpressApp(); + + // Map to store transports by session ID + const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; + + // MCP POST endpoint + const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId) { + console.log(`Received MCP request for session: ${sessionId}`); + } + + try { + let transport: NodeStreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + // Reuse existing transport for this session + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - create new transport + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect a fresh MCP server to the transport BEFORE handling the request + const mcpServer = getServer(); + await mcpServer.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + // Handle the request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } + }; + + app.post('/mcp', mcpPostHandler); + + // Handle GET requests for SSE streams + const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + }; + + app.get('/mcp', mcpGetHandler); + + // Handle DELETE requests for session termination + const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } + }; + + app.delete('/mcp', mcpDeleteHandler); + + // Start listening + app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`Form elicitation example server is running on http://localhost:${PORT}/mcp`); + console.log('Available tools:'); + console.log(' - register_user: Collect user registration information'); + console.log(' - create_event: Multi-step event creation'); + console.log(' - update_shipping_address: Collect and validate address'); + console.log('\nConnect your MCP client to this server using the HTTP transport.'); + }); + + // Handle server shutdown + process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId]!.close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); + }); +} + +try { + await main(); +} catch (error) { + console.error('Server error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/src/elicitationUrlExample.ts b/examples/server/src/elicitationUrlExample.ts new file mode 100644 index 0000000..93b5915 --- /dev/null +++ b/examples/server/src/elicitationUrlExample.ts @@ -0,0 +1,738 @@ +// Run with: pnpm tsx src/elicitationUrlExample.ts +// +// This example demonstrates how to use URL elicitation to securely collect +// *sensitive* user input in a remote (HTTP) server. +// URL elicitation allows servers to prompt the end-user to open a URL in their browser +// to collect sensitive information. +// Note: See also elicitationFormExample.ts for an example of using form (not URL) elicitation +// to collect *non-sensitive* user input with a structured schema. + +import { randomUUID } from 'node:crypto'; + +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import * as z from 'zod/v4'; + +import { InMemoryEventStore } from './inMemoryEventStore.js'; + +// Create an MCP server with implementation details +const getServer = () => { + const mcpServer = new McpServer( + { + name: 'url-elicitation-http-server', + version: '1.0.0' + }, + { + capabilities: { logging: {} } + } + ); + + mcpServer.registerTool( + 'payment-confirm', + { + description: 'A tool that confirms a payment directly with a user', + inputSchema: z.object({ + cartId: z.string().describe('The ID of the cart to confirm') + }) + }, + async ({ cartId }, ctx): Promise => { + /* + In a real world scenario, there would be some logic here to check if the user has the provided cartId. + For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment) + */ + const sessionId = ctx.sessionId; + if (!sessionId) { + throw new Error('Expected a Session ID'); + } + + // Create and track the elicitation + const elicitationId = generateTrackedElicitation(sessionId, elicitationId => + mcpServer.server.createElicitationCompletionNotifier(elicitationId) + ); + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'This tool requires a payment confirmation. Open the link to confirm payment!', + url: `http://localhost:${MCP_PORT}/confirm-payment?session=${sessionId}&elicitation=${elicitationId}&cartId=${encodeURIComponent(cartId)}`, + elicitationId + } + ]); + } + ); + + mcpServer.registerTool( + 'third-party-auth', + { + description: 'A demo tool that requires third-party OAuth credentials', + inputSchema: z.object({ + param1: z.string().describe('First parameter') + }) + }, + async (_, ctx): Promise => { + /* + In a real world scenario, there would be some logic here to check if we already have a valid access token for the user. + Auth info (with a subject or `sub` claim) can be typically be found in `ctx.http?.authInfo`. + If we do, we can just return the result of the tool call. + If we don't, we can throw an ElicitationRequiredError to request the user to authenticate. + For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate). + */ + const sessionId = ctx.sessionId; + if (!sessionId) { + throw new Error('Expected a Session ID'); + } + + // Create and track the elicitation + const elicitationId = generateTrackedElicitation(sessionId, elicitationId => + mcpServer.server.createElicitationCompletionNotifier(elicitationId) + ); + + // Simulate OAuth callback and token exchange after 5 seconds + // In a real app, this would be called from your OAuth callback handler + setTimeout(() => { + console.log(`Simulating OAuth token received for elicitation ${elicitationId}`); + completeURLElicitation(elicitationId); + }, 5000); + + throw new UrlElicitationRequiredError([ + { + mode: 'url', + message: 'This tool requires access to your example.com account. Open the link to authenticate!', + url: 'https://www.example.com/oauth/authorize', + elicitationId + } + ]); + } + ); + + return mcpServer; +}; + +/** + * Elicitation Completion Tracking Utilities + **/ + +interface ElicitationMetadata { + status: 'pending' | 'complete'; + completedPromise: Promise; + completeResolver: () => void; + createdAt: Date; + sessionId: string; + completionNotifier?: () => Promise; +} + +const elicitationsMap = new Map(); + +// Clean up old elicitations after 1 hour to prevent memory leaks +const ELICITATION_TTL_MS = 60 * 60 * 1000; // 1 hour +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +function cleanupOldElicitations() { + const now = new Date(); + for (const [id, metadata] of elicitationsMap.entries()) { + if (now.getTime() - metadata.createdAt.getTime() > ELICITATION_TTL_MS) { + elicitationsMap.delete(id); + console.log(`Cleaned up expired elicitation: ${id}`); + } + } +} + +setInterval(cleanupOldElicitations, CLEANUP_INTERVAL_MS); + +/** + * Elicitation IDs must be unique strings within the MCP session + * UUIDs are used in this example for simplicity + */ +function generateElicitationId(): string { + return randomUUID(); +} + +/** + * Helper function to create and track a new elicitation. + */ +function generateTrackedElicitation(sessionId: string, createCompletionNotifier?: ElicitationCompletionNotifierFactory): string { + const elicitationId = generateElicitationId(); + + // Create a Promise and its resolver for tracking completion + let completeResolver: () => void; + const completedPromise = new Promise(resolve => { + completeResolver = resolve; + }); + + const completionNotifier = createCompletionNotifier ? createCompletionNotifier(elicitationId) : undefined; + + // Store the elicitation in our map + elicitationsMap.set(elicitationId, { + status: 'pending', + completedPromise, + completeResolver: completeResolver!, + createdAt: new Date(), + sessionId, + completionNotifier + }); + + return elicitationId; +} + +/** + * Helper function to complete an elicitation. + */ +function completeURLElicitation(elicitationId: string) { + const elicitation = elicitationsMap.get(elicitationId); + if (!elicitation) { + console.warn(`Attempted to complete unknown elicitation: ${elicitationId}`); + return; + } + + if (elicitation.status === 'complete') { + console.warn(`Elicitation already complete: ${elicitationId}`); + return; + } + + // Update metadata + elicitation.status = 'complete'; + + // Send completion notification to the client + if (elicitation.completionNotifier) { + console.log(`Sending notifications/elicitation/complete notification for elicitation ${elicitationId}`); + + elicitation.completionNotifier().catch(error => { + console.error(`Failed to send completion notification for elicitation ${elicitationId}:`, error); + }); + } + + // Resolve the promise to unblock any waiting code + elicitation.completeResolver(); +} + +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; + +const app = createMcpExpressApp(); + +// Allow CORS all domains, expose the Mcp-Session-Id header +app.use( + cors({ + origin: '*', // Allow all origins + exposedHeaders: ['Mcp-Session-Id'], + credentials: true // Allow cookies to be sent cross-origin + }) +); + +// Set up OAuth (required for this example) +let authMiddleware = null; +// Create auth middleware for MCP endpoints +const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); +const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + +setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true }); + +// Add protected resource metadata route to the MCP server +// This allows clients to discover the auth server +// Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp +app.use(createProtectedResourceMetadataRouter('/mcp')); + +authMiddleware = requireBearerAuth({ + verifier: demoTokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); + +/** + * API Key Form Handling + * + * Many servers today require an API key to operate, but there's no scalable way to do this dynamically for remote servers within MCP protocol. + * URL-mode elicitation enables the server to host a simple form and get the secret data securely from the user without involving the LLM or client. + **/ + +async function sendApiKeyElicitation( + sessionId: string, + sender: ElicitationSender, + createCompletionNotifier: ElicitationCompletionNotifierFactory +) { + if (!sessionId) { + console.error('No session ID provided'); + throw new Error('Expected a Session ID to track elicitation'); + } + + console.log('🔑 URL elicitation demo: Requesting API key from client...'); + const elicitationId = generateTrackedElicitation(sessionId, createCompletionNotifier); + try { + const result = await sender({ + mode: 'url', + message: 'Please provide your API key to authenticate with this server', + // Host the form on the same server. In a real app, you might coordinate passing these state variables differently. + url: `http://localhost:${MCP_PORT}/api-key-form?session=${sessionId}&elicitation=${elicitationId}`, + elicitationId + }); + + switch (result.action) { + case 'accept': { + console.log('🔑 URL elicitation demo: Client accepted the API key elicitation (now pending form submission)'); + // Wait for the API key to be submitted via the form + // The form submission will complete the elicitation + break; + } + default: { + console.log('🔑 URL elicitation demo: Client declined to provide an API key'); + // In a real app, this might close the connection, but for the demo, we'll continue + break; + } + } + } catch (error) { + console.error('Error during API key elicitation:', error); + } +} + +// API Key Form endpoint - serves a simple HTML form +app.get('/api-key-form', (req: Request, res: Response) => { + const mcpSessionId = req.query.session as string | undefined; + const elicitationId = req.query.elicitation as string | undefined; + if (!mcpSessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie + // In production, this is often handled by some user auth middleware to ensure the user has a valid session + // This session is different from the MCP session. + // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // Serve a simple HTML form + res.send(` + + + + Submit Your API Key + + + +

API Key Required

+
✓ Logged in as: ${userSession.name}
+
+ + + + +
+
This is a demo showing how a server can securely elicit sensitive data from a user using a URL.
+ + + `); +}); + +// Handle API key form submission +app.post('/api-key-form', express.urlencoded(), (req: Request, res: Response) => { + const { session: sessionId, apiKey, elicitation: elicitationId } = req.body; + if (!sessionId || !apiKey || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie here too + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // A real app might store this API key to be used later for the user. + console.log(`🔑 Received API key \u001B[32m${apiKey}\u001B[0m for session ${sessionId}`); + + // If we have an elicitationId, complete the elicitation + completeURLElicitation(elicitationId); + + // Send a success response + res.send(` + + + + Success + + + +
+

Success ✓

+

API key received.

+
+

You can close this window and return to your MCP client.

+ + + `); +}); + +// Helper to get the user session from the demo_session cookie +function getUserSessionCookie(cookieHeader?: string): { userId: string; name: string; timestamp: number } | null { + if (!cookieHeader) return null; + + const cookies = cookieHeader.split(';'); + for (const cookie of cookies) { + const [name, value] = cookie.trim().split('='); + if (name === 'demo_session' && value) { + try { + return JSON.parse(decodeURIComponent(value)); + } catch (error) { + console.error('Failed to parse demo_session cookie:', error); + return null; + } + } + } + return null; +} + +/** + * Payment Confirmation Form Handling + * + * This demonstrates how a server can use URL-mode elicitation to get user confirmation + * for sensitive operations like payment processing. + **/ + +// Payment Confirmation Form endpoint - serves a simple HTML form +app.get('/confirm-payment', (req: Request, res: Response) => { + const mcpSessionId = req.query.session as string | undefined; + const elicitationId = req.query.elicitation as string | undefined; + const cartId = req.query.cartId as string | undefined; + if (!mcpSessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie + // In production, this is often handled by some user auth middleware to ensure the user has a valid session + // This session is different from the MCP session. + // This userSession is the cookie that the MCP Server's Authorization Server sets for the user when they log in. + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + // Serve a simple HTML form + res.send(` + + + + Confirm Payment + + + +

Confirm Payment

+
✓ Logged in as: ${userSession.name}
+ ${cartId ? `
Cart ID: ${cartId}
` : ''} +
+ ⚠️ Please review your order before confirming. +
+
+ + + ${cartId ? `` : ''} + + +
+
This is a demo showing how a server can securely get user confirmation for sensitive operations using URL-mode elicitation.
+ + + `); +}); + +// Handle Payment Confirmation form submission +app.post('/confirm-payment', express.urlencoded(), (req: Request, res: Response) => { + const { session: sessionId, elicitation: elicitationId, cartId, action } = req.body; + if (!sessionId || !elicitationId) { + res.status(400).send('

Error

Missing required parameters

'); + return; + } + + // Check for user session cookie here too + const userSession = getUserSessionCookie(req.headers.cookie); + if (!userSession) { + res.status(401).send('

Error

Unauthorized - please reconnect to login again

'); + return; + } + + if (action === 'confirm') { + // A real app would process the payment here + console.log(`💳 Payment confirmed for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); + + // Complete the elicitation + completeURLElicitation(elicitationId); + + // Send a success response + res.send(` + + + + Payment Confirmed + + + +
+

Payment Confirmed ✓

+

Your payment has been successfully processed.

+ ${cartId ? `

Cart ID: ${cartId}

` : ''} +
+

You can close this window and return to your MCP client.

+ + + `); + } else if (action === 'cancel') { + console.log(`💳 Payment cancelled for cart ${cartId || 'unknown'} by user ${userSession.name} (session ${sessionId})`); + + // The client will still receive a notifications/elicitation/complete notification, + // which indicates that the out-of-band interaction is complete (but not necessarily successful) + completeURLElicitation(elicitationId); + + res.send(` + + + + Payment Cancelled + + + +
+

Payment Cancelled

+

Your payment has been cancelled.

+
+

You can close this window and return to your MCP client.

+ + + `); + } else { + res.status(400).send('

Error

Invalid action

'); + } +}); + +// Map to store transports by session ID +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; + +// Interface for a function that can send an elicitation request +type ElicitationSender = (params: ElicitRequestURLParams) => Promise; +type ElicitationCompletionNotifierFactory = (elicitationId: string) => () => Promise; + +// Track sessions that need an elicitation request to be sent +interface SessionElicitationInfo { + elicitationSender: ElicitationSender; + createCompletionNotifier: ElicitationCompletionNotifierFactory; +} +const sessionsNeedingElicitation: { [sessionId: string]: SessionElicitationInfo } = {}; + +// MCP POST endpoint +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + console.debug(`Received MCP POST for session: ${sessionId || 'unknown'}`); + + try { + let transport: NodeStreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const server = getServer(); + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + sessionsNeedingElicitation[sessionId] = { + elicitationSender: params => server.server.elicitInput(params), + createCompletionNotifier: elicitationId => server.server.createElicitationCompletionNotifier(elicitationId) + }; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + delete sessionsNeedingElicitation[sid]; + } + }; + + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}; + +// Set up routes with auth middleware +app.post('/mcp', authMiddleware, mcpPostHandler); + +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) +const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + + if (sessionsNeedingElicitation[sessionId]) { + const { elicitationSender, createCompletionNotifier } = sessionsNeedingElicitation[sessionId]; + + // Send an elicitation request to the client in the background + sendApiKeyElicitation(sessionId, elicitationSender, createCompletionNotifier) + .then(() => { + // Only delete on successful send for this demo + delete sessionsNeedingElicitation[sessionId]; + console.log(`🔑 URL elicitation demo: Finished sending API key elicitation request for session ${sessionId}`); + }) + .catch(error => { + console.error('Error sending API key elicitation:', error); + // Keep in map to potentially retry on next reconnect + }); + } +}; + +// Set up GET route with conditional auth middleware +app.get('/mcp', authMiddleware, mcpGetHandler); + +// Handle DELETE requests for session termination (according to MCP spec) +const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}; + +// Set up DELETE route with auth middleware +app.delete('/mcp', authMiddleware, mcpDeleteHandler); + +app.listen(MCP_PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); + console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId]!.close(); + delete transports[sessionId]; + delete sessionsNeedingElicitation[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/examples/server/src/honoWebStandardStreamableHttp.ts b/examples/server/src/honoWebStandardStreamableHttp.ts new file mode 100644 index 0000000..b15f988 --- /dev/null +++ b/examples/server/src/honoWebStandardStreamableHttp.ts @@ -0,0 +1,73 @@ +/** + * Example MCP server using Hono with WebStandardStreamableHTTPServerTransport + * + * This example demonstrates using the Web Standard transport directly with Hono, + * which works on any runtime: Node.js, Cloudflare Workers, Deno, Bun, etc. + * + * Run with: pnpm tsx src/honoWebStandardStreamableHttp.ts + */ + +import { serve } from '@hono/node-server'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import * as z from 'zod/v4'; + +// Create the MCP server +const server = new McpServer({ + name: 'hono-webstandard-mcp-server', + version: '1.0.0' +}); + +// Register a simple greeting tool +server.registerTool( + 'greet', + { + title: 'Greeting Tool', + description: 'A simple greeting tool', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => { + return { + content: [{ type: 'text', text: `Hello, ${name}! (from Hono + WebStandard transport)` }] + }; + } +); + +// Create a stateless transport (no options = no session management) +const transport = new WebStandardStreamableHTTPServerTransport(); + +// Create the Hono app +const app = new Hono(); + +// Enable CORS for all origins +app.use( + '*', + cors({ + origin: '*', + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'], + exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] + }) +); + +// Health check endpoint +app.get('/health', c => c.json({ status: 'ok' })); + +// MCP endpoint +app.all('/mcp', c => transport.handleRequest(c.req.raw)); + +// Start the server +const PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; + +await server.connect(transport); + +console.log(`Starting Hono MCP server on port ${PORT}`); +console.log(`Health check: http://localhost:${PORT}/health`); +console.log(`MCP endpoint: http://localhost:${PORT}/mcp`); + +serve({ + fetch: app.fetch, + port: PORT +}); diff --git a/examples/server/src/inMemoryEventStore.ts b/examples/server/src/inMemoryEventStore.ts new file mode 100644 index 0000000..604b84d --- /dev/null +++ b/examples/server/src/inMemoryEventStore.ts @@ -0,0 +1,77 @@ +import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; + +/** + * Simple in-memory implementation of the EventStore interface for resumability + * This is primarily intended for examples and testing, not for production use + * where a persistent storage solution would be more appropriate. + */ +export class InMemoryEventStore implements EventStore { + private events: Map = new Map(); + + /** + * Generates a unique event ID for a given stream ID + */ + private generateEventId(streamId: string): string { + return `${streamId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + } + + /** + * Extracts the stream ID from an event ID + */ + private getStreamIdFromEventId(eventId: string): string { + const parts = eventId.split('_'); + return parts.length > 0 ? parts[0]! : ''; + } + + /** + * Stores an event with a generated event ID + * Implements EventStore.storeEvent + */ + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = this.generateEventId(streamId); + this.events.set(eventId, { streamId, message }); + return eventId; + } + + /** + * Replays events that occurred after a specific event ID + * Implements EventStore.replayEventsAfter + */ + async replayEventsAfter( + lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) { + return ''; + } + + // Extract the stream ID from the event ID + const streamId = this.getStreamIdFromEventId(lastEventId); + if (!streamId) { + return ''; + } + + let foundLastEvent = false; + + // Sort events by eventId for chronological ordering + const sortedEvents = [...this.events.entries()].toSorted((a, b) => a[0].localeCompare(b[0])); + + for (const [eventId, { streamId: eventStreamId, message }] of sortedEvents) { + // Only include events from the same stream + if (eventStreamId !== streamId) { + continue; + } + + // Start sending events after we find the lastEventId + if (eventId === lastEventId) { + foundLastEvent = true; + continue; + } + + if (foundLastEvent) { + await send(eventId, message); + } + } + return streamId; + } +} diff --git a/examples/server/src/jsonResponseStreamableHttp.ts b/examples/server/src/jsonResponseStreamableHttp.ts new file mode 100644 index 0000000..01759d6 --- /dev/null +++ b/examples/server/src/jsonResponseStreamableHttp.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +// Create an MCP server with implementation details +const getServer = () => { + const server = new McpServer( + { + name: 'json-response-streamable-http-server', + version: '1.0.0' + }, + { + capabilities: { + logging: {} + } + } + ); + + // Register a simple tool that returns a greeting + server.registerTool( + 'greet', + { + description: 'A simple greeting tool', + inputSchema: z.object({ + name: z.string().describe('Name to greet') + }) + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!` + } + ] + }; + } + ); + + // Register a tool that sends multiple greetings with notifications + server.registerTool( + 'multi-greet', + { + description: 'A tool that sends different greetings with delays between them', + inputSchema: z.object({ + name: z.string().describe('Name to greet') + }) + }, + async ({ name }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); + + await sleep(1000); // Wait 1 second before first greeting + + await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); + + await sleep(1000); // Wait another second before second greeting + + await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!` + } + ] + }; + } + ); + return server; +}; + +const app = createMcpExpressApp(); + +// Map to store transports by session ID +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - use JSON response mode + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, // Enable JSON response mode + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Connect the transport to the MCP server BEFORE handling the request + const server = getServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests for SSE streams according to spec +app.get('/mcp', async (req: Request, res: Response) => { + // Since this is a very simple example, we don't support GET requests for this server + // The spec requires returning 405 Method Not Allowed in this case + res.status(405).set('Allow', 'POST').send('Method Not Allowed'); +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + process.exit(0); +}); diff --git a/examples/server/src/mcpServerOutputSchema.ts b/examples/server/src/mcpServerOutputSchema.ts new file mode 100644 index 0000000..955855c --- /dev/null +++ b/examples/server/src/mcpServerOutputSchema.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Example MCP server using the high-level McpServer API with outputSchema + * This demonstrates how to easily create tools with structured output + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const server = new McpServer({ + name: 'mcp-output-schema-high-level-example', + version: '1.0.0' +}); + +// Define a tool with structured output - Weather data +server.registerTool( + 'get_weather', + { + description: 'Get weather information for a city', + inputSchema: z.object({ + city: z.string().describe('City name'), + country: z.string().describe('Country code (e.g., US, UK)') + }), + outputSchema: z.object({ + temperature: z.object({ + celsius: z.number(), + fahrenheit: z.number() + }), + conditions: z.enum(['sunny', 'cloudy', 'rainy', 'stormy', 'snowy']), + humidity: z.number().min(0).max(100), + wind: z.object({ + speed_kmh: z.number(), + direction: z.string() + }) + }) + }, + async ({ city, country }) => { + // Parameters are available but not used in this example + void city; + void country; + // Simulate weather API call + const temp_c = Math.round((Math.random() * 35 - 5) * 10) / 10; + const conditions = ['sunny', 'cloudy', 'rainy', 'stormy', 'snowy'][Math.floor(Math.random() * 5)]; + + const structuredContent = { + temperature: { + celsius: temp_c, + fahrenheit: Math.round(((temp_c * 9) / 5 + 32) * 10) / 10 + }, + conditions, + humidity: Math.round(Math.random() * 100), + wind: { + speed_kmh: Math.round(Math.random() * 50), + direction: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)] + } + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(structuredContent, null, 2) + } + ], + structuredContent + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('High-level Output Schema Example Server running on stdio'); +} + +try { + await main(); +} catch (error) { + console.error('Server error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/src/resourceServerOnly.ts b/examples/server/src/resourceServerOnly.ts new file mode 100644 index 0000000..1a17081 --- /dev/null +++ b/examples/server/src/resourceServerOnly.ts @@ -0,0 +1,87 @@ +/** + * Minimal Resource-Server-only auth using the SDK's RS helpers + * (`mcpAuthMetadataRouter`, `requireBearerAuth`, `OAuthTokenVerifier`). + * + * No better-auth. The Authorization Server is external; this example points + * its metadata at a placeholder issuer. For a full AS+RS setup with a real + * demo Authorization Server, see {@link ./simpleStreamableHttp.ts}. + * + * Run: pnpm tsx src/resourceServerOnly.ts + * Probe: curl http://localhost:3000/.well-known/oauth-protected-resource/mcp + * curl -H 'Authorization: Bearer demo-token' -X POST http://localhost:3000/mcp ... + */ + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import { + createMcpExpressApp, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth +} from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { AuthInfo, CallToolResult, OAuthMetadata } from '@modelcontextprotocol/server'; +import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const PORT = 3000; +const mcpServerUrl = new URL(`http://localhost:${PORT}/mcp`); + +// In a real deployment this is your external Authorization Server's metadata +// (RFC 8414). The SDK router serves it verbatim at +// /.well-known/oauth-authorization-server so clients probing the RS origin +// can still discover the AS. +const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] +}; + +// Replace with JWT verification, RFC 7662 introspection, etc. +const staticTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token): Promise { + if (token !== 'demo-token') { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); + } + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + } +}; + +const server = new McpServer({ name: 'rs-only', version: '1.0.0' }, { capabilities: {} }); +server.registerTool( + 'whoami', + { description: 'Returns the authenticated subject.', inputSchema: z.object({}) }, + async (_args, ctx): Promise => ({ + content: [{ type: 'text', text: `client=${ctx.http?.authInfo?.clientId ?? 'anon'}` }] + }) +); + +const app = createMcpExpressApp(); + +app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + resourceName: 'RS-only example' + }) +); + +const auth = requireBearerAuth({ + verifier: staticTokenVerifier, + requiredScopes: ['mcp'], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) +}); + +app.post('/mcp', auth, async (req: Request, res: Response) => { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => void transport.close()); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(PORT, () => { + console.log(`RS-only MCP server on http://localhost:${PORT}/mcp`); + console.log(` PRM: ${getOAuthProtectedResourceMetadataUrl(mcpServerUrl)}`); + console.log(` AS metadata mirror: http://localhost:${PORT}/.well-known/oauth-authorization-server`); +}); diff --git a/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts new file mode 100644 index 0000000..5a4712f --- /dev/null +++ b/examples/server/src/serverGuide.examples.ts @@ -0,0 +1,560 @@ +/** + * Type-checked examples for docs/server.md. + * + * Regions are synced into markdown code fences via `pnpm sync:snippets`. + * Each function wraps a single region. The function name matches the region name. + * + * @module + */ + +//#region imports +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, ResourceLink } from '@modelcontextprotocol/server'; +import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; +//#endregion imports + +// --------------------------------------------------------------------------- +// Server instructions +// --------------------------------------------------------------------------- + +/** Example: McpServer with instructions for LLM guidance. */ +function instructions_basic() { + //#region instructions_basic + const server = new McpServer( + { name: 'db-server', version: '1.0.0' }, + { + instructions: + 'Always call list_tables before running queries. Use validate_schema before migrate_schema for safe migrations. Results are limited to 1000 rows.' + } + ); + //#endregion instructions_basic + return server; +} + +// --------------------------------------------------------------------------- +// Tools, resources, and prompts +// --------------------------------------------------------------------------- + +/** Example: Registering a tool with inputSchema, outputSchema, and structuredContent. */ +function registerTool_basic(server: McpServer) { + //#region registerTool_basic + server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: z.object({ + weightKg: z.number(), + heightM: z.number() + }), + outputSchema: z.object({ bmi: z.number() }) + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + //#endregion registerTool_basic +} + +/** Example: Tool returning resource_link content items. */ +function registerTool_resourceLink(server: McpServer) { + //#region registerTool_resourceLink + server.registerTool( + 'list-files', + { + title: 'List Files', + description: 'Returns files as resource links without embedding content' + }, + async (): Promise => { + const links: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'file:///projects/readme.md', + name: 'README', + mimeType: 'text/markdown' + }, + { + type: 'resource_link', + uri: 'file:///projects/config.json', + name: 'Config', + mimeType: 'application/json' + } + ]; + return { content: links }; + } + ); + //#endregion registerTool_resourceLink +} + +/** Example: Tool with explicit error handling using isError. */ +function registerTool_errorHandling(server: McpServer) { + //#region registerTool_errorHandling + server.registerTool( + 'fetch-data', + { + description: 'Fetch data from a URL', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }): Promise => { + try { + const res = await fetch(url); + if (!res.ok) { + return { + content: [{ type: 'text', text: `HTTP ${res.status}: ${res.statusText}` }], + isError: true + }; + } + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } catch (error) { + return { + content: [{ type: 'text', text: `Failed: ${error instanceof Error ? error.message : String(error)}` }], + isError: true + }; + } + } + ); + //#endregion registerTool_errorHandling +} + +/** Example: Tool with annotations hinting at behavior. */ +function registerTool_annotations(server: McpServer) { + //#region registerTool_annotations + server.registerTool( + 'delete-file', + { + description: 'Delete a file from the project', + inputSchema: z.object({ path: z.string() }), + annotations: { + title: 'Delete File', + destructiveHint: true, + idempotentHint: true + } + }, + async ({ path }): Promise => { + // ... perform deletion ... + return { content: [{ type: 'text', text: `Deleted ${path}` }] }; + } + ); + //#endregion registerTool_annotations +} + +/** Example: Registering a static resource at a fixed URI. */ +function registerResource_static(server: McpServer) { + //#region registerResource_static + server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + description: 'Application configuration data', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) + ); + //#endregion registerResource_static +} + +/** Example: Dynamic resource with ResourceTemplate and listing. */ +function registerResource_template(server: McpServer) { + //#region registerResource_template + server.registerResource( + 'user-profile', + new ResourceTemplate('user://{userId}/profile', { + list: async () => ({ + resources: [ + { uri: 'user://123/profile', name: 'Alice' }, + { uri: 'user://456/profile', name: 'Bob' } + ] + }) + }), + { + title: 'User Profile', + description: 'User profile data', + mimeType: 'application/json' + }, + async (uri, { userId }) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify({ userId, name: 'Example User' }) + } + ] + }) + ); + //#endregion registerResource_template +} + +/** Example: Registering a prompt with argsSchema. */ +function registerPrompt_basic(server: McpServer) { + //#region registerPrompt_basic + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices and potential issues', + argsSchema: z.object({ + code: z.string() + }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Please review this code:\n\n${code}` + } + } + ] + }) + ); + //#endregion registerPrompt_basic +} + +/** Example: Prompt with completable argsSchema for autocompletion. */ +function registerPrompt_completion(server: McpServer) { + //#region registerPrompt_completion + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code for best practices.` + } + } + ] + }) + ); + //#endregion registerPrompt_completion +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +/** Example: Server with logging capability + tool that logs progress messages. */ +function registerTool_logging() { + //#region logging_capability + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + //#endregion logging_capability + + //#region registerTool_logging + server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } + ); + //#endregion registerTool_logging + return server; +} + +// --------------------------------------------------------------------------- +// Progress +// --------------------------------------------------------------------------- + +/** Example: Tool that sends progress notifications during a long-running operation. */ +function registerTool_progress(server: McpServer) { + //#region registerTool_progress + server.registerTool( + 'process-files', + { + description: 'Process files with progress updates', + inputSchema: z.object({ files: z.array(z.string()) }) + }, + async ({ files }, ctx): Promise => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + for (let i = 0; i < files.length; i++) { + // ... process files[i] ... + + if (progressToken !== undefined) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: i + 1, + total: files.length, + message: `Processed ${files[i]}` + } + }); + } + } + + return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; + } + ); + //#endregion registerTool_progress +} + +// --------------------------------------------------------------------------- +// Server-initiated requests +// --------------------------------------------------------------------------- + +/** Example: Tool that uses sampling to request an LLM completion from the client. */ +function registerTool_sampling(server: McpServer) { + //#region registerTool_sampling + server.registerTool( + 'summarize', + { + description: 'Summarize text using the client LLM', + inputSchema: z.object({ text: z.string() }) + }, + async ({ text }, ctx): Promise => { + const response = await ctx.mcpReq.requestSampling({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please summarize:\n\n${text}` + } + } + ], + maxTokens: 500 + }); + return { + content: [ + { + type: 'text', + text: `Model (${response.model}): ${JSON.stringify(response.content)}` + } + ] + }; + } + ); + //#endregion registerTool_sampling +} + +/** Example: Tool that uses form elicitation to collect user input. */ +function registerTool_elicitation(server: McpServer) { + //#region registerTool_elicitation + server.registerTool( + 'collect-feedback', + { + description: 'Collect user feedback via a form', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: 'Please share your feedback:', + requestedSchema: { + type: 'object', + properties: { + rating: { + type: 'number', + title: 'Rating (1\u20135)', + minimum: 1, + maximum: 5 + }, + comment: { type: 'string', title: 'Comment' } + }, + required: ['rating'] + } + }); + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thanks! ${JSON.stringify(result.content)}` + } + ] + }; + } + return { content: [{ type: 'text', text: 'Feedback declined.' }] }; + } + ); + //#endregion registerTool_elicitation +} + +/** Example: Tool that requests the client's filesystem roots. */ +function registerTool_roots(server: McpServer) { + //#region registerTool_roots + server.registerTool( + 'list-workspace-files', + { + description: 'List files across all workspace roots', + inputSchema: z.object({}) + }, + async (_args, _ctx): Promise => { + const { roots } = await server.server.listRoots(); + const summary = roots.map(r => `${r.name ?? r.uri}: ${r.uri}`).join('\n'); + return { content: [{ type: 'text', text: summary }] }; + } + ); + //#endregion registerTool_roots +} + +// --------------------------------------------------------------------------- +// Transports +// --------------------------------------------------------------------------- + +/** Example: Stateful Streamable HTTP transport with session management. */ +async function streamableHttp_stateful() { + //#region streamableHttp_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await server.connect(transport); + //#endregion streamableHttp_stateful +} + +/** Example: Stateless Streamable HTTP transport (no session persistence). */ +async function streamableHttp_stateless() { + //#region streamableHttp_stateless + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + await server.connect(transport); + //#endregion streamableHttp_stateless +} + +/** Example: Streamable HTTP with JSON response mode (no SSE). */ +async function streamableHttp_jsonResponse() { + //#region streamableHttp_jsonResponse + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + + await server.connect(transport); + //#endregion streamableHttp_jsonResponse +} + +/** Example: stdio transport for local process-spawned integrations. */ +async function stdio_basic() { + //#region stdio_basic + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion stdio_basic +} + +// --------------------------------------------------------------------------- +// Shutdown +// --------------------------------------------------------------------------- + +/** Example: Graceful shutdown for a stateful multi-session HTTP server. */ +function shutdown_statefulHttp(app: ReturnType, transports: Map) { + //#region shutdown_statefulHttp + // Capture the http.Server so it can be closed on shutdown + const httpServer = app.listen(3000); + + process.on('SIGINT', async () => { + httpServer.close(); + + for (const [sessionId, transport] of transports) { + await transport.close(); + transports.delete(sessionId); + } + + process.exit(0); + }); + //#endregion shutdown_statefulHttp +} + +/** Example: Graceful shutdown for a stdio server. */ +function shutdown_stdio(server: McpServer) { + //#region shutdown_stdio + process.on('SIGINT', async () => { + await server.close(); + process.exit(0); + }); + //#endregion shutdown_stdio +} + +// --------------------------------------------------------------------------- +// DNS rebinding protection +// --------------------------------------------------------------------------- + +/** Example: createMcpExpressApp with different host bindings. */ +function dnsRebinding_basic() { + //#region dnsRebinding_basic + // Default: DNS rebinding protection auto-enabled (host is 127.0.0.1) + const app = createMcpExpressApp(); + + // DNS rebinding protection also auto-enabled for localhost + const appLocal = createMcpExpressApp({ host: 'localhost' }); + + // No automatic protection when binding to all interfaces + const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); + //#endregion dnsRebinding_basic + return { app, appLocal, appOpen }; +} + +/** Example: createMcpExpressApp with allowedHosts for non-localhost binding. */ +function dnsRebinding_allowedHosts() { + //#region dnsRebinding_allowedHosts + const app = createMcpExpressApp({ + host: '0.0.0.0', + allowedHosts: ['localhost', '127.0.0.1', 'myhost.local'] + }); + //#endregion dnsRebinding_allowedHosts + return app; +} + +// Suppress unused-function warnings (functions exist solely for type-checking) +void instructions_basic; +void registerTool_basic; +void registerTool_resourceLink; +void registerTool_errorHandling; +void registerTool_annotations; +void registerTool_logging; +void registerTool_progress; +void registerTool_sampling; +void registerTool_elicitation; +void registerTool_roots; +void registerResource_static; +void registerResource_template; +void registerPrompt_basic; +void registerPrompt_completion; +void streamableHttp_stateful; +void streamableHttp_stateless; +void streamableHttp_jsonResponse; +void stdio_basic; +void shutdown_statefulHttp; +void shutdown_stdio; +void dnsRebinding_basic; +void dnsRebinding_allowedHosts; diff --git a/examples/server/src/simpleStatelessStreamableHttp.ts b/examples/server/src/simpleStatelessStreamableHttp.ts new file mode 100644 index 0000000..2b4f036 --- /dev/null +++ b/examples/server/src/simpleStatelessStreamableHttp.ts @@ -0,0 +1,171 @@ +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +const getServer = () => { + // Create an MCP server with implementation details + const server = new McpServer( + { + name: 'stateless-streamable-http-server', + version: '1.0.0' + }, + { capabilities: { logging: {} } } + ); + + // Register a simple prompt + server.registerPrompt( + 'greeting-template', + { + description: 'A simple greeting prompt template', + argsSchema: z.object({ + name: z.string().describe('Name to include in greeting') + }) + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.` + } + } + ] + }; + } + ); + + // Register a tool specifically for testing resumability + server.registerTool( + 'start-notification-stream', + { + description: 'Starts sending periodic notifications for testing resumability', + inputSchema: z.object({ + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(10) + }) + }, + async ({ interval, count }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); + } catch (error) { + console.error('Error sending notification:', error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms` + } + ] + }; + } + ); + + // Create a simple resource at a fixed URI + server.registerResource( + 'greeting-resource', + 'https://example.com/greetings/default', + { mimeType: 'text/plain' }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!' + } + ] + }; + } + ); + return server; +}; + +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + const server = getServer(); + try { + const transport: NodeStreamableHTTPServerTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + res.on('close', () => { + console.log('Request closed'); + transport.close(); + server.close(); + }); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET MCP request'); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Method not allowed.' + }, + id: null + }) + ); +}); + +app.delete('/mcp', async (req: Request, res: Response) => { + console.log('Received DELETE MCP request'); + res.writeHead(405).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Method not allowed.' + }, + id: null + }) + ); +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}); diff --git a/examples/server/src/simpleStreamableHttp.ts b/examples/server/src/simpleStreamableHttp.ts new file mode 100644 index 0000000..6da0841 --- /dev/null +++ b/examples/server/src/simpleStreamableHttp.ts @@ -0,0 +1,821 @@ +import { randomUUID } from 'node:crypto'; + +import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@modelcontextprotocol/examples-shared'; +import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { + CallToolResult, + ElicitResult, + GetPromptResult, + PrimitiveSchemaDefinition, + ReadResourceResult, + ResourceLink +} from '@modelcontextprotocol/server'; +import { InMemoryTaskMessageQueue, InMemoryTaskStore, isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + +import { InMemoryEventStore } from './inMemoryEventStore.js'; + +// Check for OAuth flag +const useOAuth = process.argv.includes('--oauth'); +const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled'); + +// Create shared task store for demonstration +const taskStore = new InMemoryTaskStore(); + +// Create an MCP server with implementation details +const getServer = () => { + const server = new McpServer( + { + name: 'simple-streamable-http-server', + version: '1.0.0', + icons: [{ src: './mcp.svg', sizes: ['512x512'], mimeType: 'image/svg+xml' }], + websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk' + }, + { + capabilities: { + logging: {}, + tasks: { + requests: { tools: { call: {} } }, + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue() + } + } + } + ); + + // Register a simple tool that returns a greeting + server.registerTool( + 'greet', + { + title: 'Greeting Tool', // Display name for UI + description: 'A simple greeting tool', + inputSchema: z.object({ + name: z.string().describe('Name to greet') + }) + }, + async ({ name }): Promise => { + return { + content: [ + { + type: 'text', + text: `Hello, ${name}!` + } + ] + }; + } + ); + + // Register a tool that sends multiple greetings with notifications (with annotations) + server.registerTool( + 'multi-greet', + { + description: 'A tool that sends different greetings with delays between them', + inputSchema: z.object({ + name: z.string().describe('Name to greet') + }), + annotations: { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + } + }, + async ({ name }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`); + + await sleep(1000); // Wait 1 second before first greeting + + await ctx.mcpReq.log('info', `Sending first greeting to ${name}`); + + await sleep(1000); // Wait another second before second greeting + + await ctx.mcpReq.log('info', `Sending second greeting to ${name}`); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!` + } + ] + }; + } + ); + // Register a tool that demonstrates form elicitation (user input collection with a schema) + // This creates a closure that captures the server instance + server.registerTool( + 'collect-user-info', + { + description: 'A tool that collects user information through form elicitation', + inputSchema: z.object({ + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') + }) + }, + async ({ infoType }, ctx): Promise => { + let message: string; + let requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + + switch (infoType) { + case 'contact': { + message = 'Please provide your contact information'; + requestedSchema = { + type: 'object', + properties: { + name: { + type: 'string', + title: 'Full Name', + description: 'Your full name' + }, + email: { + type: 'string', + title: 'Email Address', + description: 'Your email address', + format: 'email' + }, + phone: { + type: 'string', + title: 'Phone Number', + description: 'Your phone number (optional)' + } + }, + required: ['name', 'email'] + }; + break; + } + case 'preferences': { + message = 'Please set your preferences'; + requestedSchema = { + type: 'object', + properties: { + theme: { + type: 'string', + title: 'Theme', + description: 'Choose your preferred theme', + enum: ['light', 'dark', 'auto'], + enumNames: ['Light', 'Dark', 'Auto'] + }, + notifications: { + type: 'boolean', + title: 'Enable Notifications', + description: 'Would you like to receive notifications?', + default: true + }, + frequency: { + type: 'string', + title: 'Notification Frequency', + description: 'How often would you like notifications?', + enum: ['daily', 'weekly', 'monthly'], + enumNames: ['Daily', 'Weekly', 'Monthly'] + } + }, + required: ['theme'] + }; + break; + } + case 'feedback': { + message = 'Please provide your feedback'; + requestedSchema = { + type: 'object', + properties: { + rating: { + type: 'integer', + title: 'Rating', + description: 'Rate your experience (1-5)', + minimum: 1, + maximum: 5 + }, + comments: { + type: 'string', + title: 'Comments', + description: 'Additional comments (optional)', + maxLength: 500 + }, + recommend: { + type: 'boolean', + title: 'Would you recommend this?', + description: 'Would you recommend this to others?' + } + }, + required: ['rating', 'recommend'] + }; + break; + } + default: { + throw new Error(`Unknown info type: ${infoType}`); + } + } + + try { + // Use sendRequest through the ctx parameter to elicit input + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + mode: 'form', + message, + requestedSchema + } + }); + + if (result.action === 'accept') { + return { + content: [ + { + type: 'text', + text: `Thank you! Collected ${infoType} information: ${JSON.stringify(result.content, null, 2)}` + } + ] + }; + } else if (result.action === 'decline') { + return { + content: [ + { + type: 'text', + text: `No information was collected. User declined ${infoType} information request.` + } + ] + }; + } else { + return { + content: [ + { + type: 'text', + text: `Information collection was cancelled by the user.` + } + ] + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error collecting ${infoType} information: ${error}` + } + ] + }; + } + } + ); + + // Register a simple prompt with title + server.registerPrompt( + 'greeting-template', + { + title: 'Greeting Template', // Display name for UI + description: 'A simple greeting prompt template', + argsSchema: z.object({ + name: z.string().describe('Name to include in greeting') + }) + }, + async ({ name }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please greet ${name} in a friendly manner.` + } + } + ] + }; + } + ); + + // Register a tool specifically for testing resumability + server.registerTool( + 'start-notification-stream', + { + description: 'Starts sending periodic notifications for testing resumability', + inputSchema: z.object({ + interval: z.number().describe('Interval in milliseconds between notifications').default(100), + count: z.number().describe('Number of notifications to send (0 for 100)').default(50) + }) + }, + async ({ interval, count }, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`); + } catch (error) { + console.error('Error sending notification:', error); + } + // Wait for the specified interval + await sleep(interval); + } + + return { + content: [ + { + type: 'text', + text: `Started sending periodic notifications every ${interval}ms` + } + ] + }; + } + ); + + // Create a simple resource at a fixed URI + server.registerResource( + 'greeting-resource', + 'https://example.com/greetings/default', + { + title: 'Default Greeting', // Display name for UI + description: 'A simple greeting resource', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'https://example.com/greetings/default', + text: 'Hello, world!' + } + ] + }; + } + ); + + // Create additional resources for ResourceLink demonstration + server.registerResource( + 'example-file-1', + 'file:///example/file1.txt', + { + title: 'Example File 1', + description: 'First example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file1.txt', + text: 'This is the content of file 1' + } + ] + }; + } + ); + + server.registerResource( + 'example-file-2', + 'file:///example/file2.txt', + { + title: 'Example File 2', + description: 'Second example file for ResourceLink demonstration', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'file:///example/file2.txt', + text: 'This is the content of file 2' + } + ] + }; + } + ); + + // Register a tool that returns ResourceLinks + server.registerTool( + 'list-files', + { + title: 'List Files with ResourceLinks', + description: 'Returns a list of files as ResourceLinks without embedding their content', + inputSchema: z.object({ + includeDescriptions: z.boolean().optional().describe('Whether to include descriptions in the resource links') + }) + }, + async ({ includeDescriptions = true }): Promise => { + const resourceLinks: ResourceLink[] = [ + { + type: 'resource_link', + uri: 'https://example.com/greetings/default', + name: 'Default Greeting', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'A simple greeting resource' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file1.txt', + name: 'Example File 1', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'First example file for ResourceLink demonstration' }) + }, + { + type: 'resource_link', + uri: 'file:///example/file2.txt', + name: 'Example File 2', + mimeType: 'text/plain', + ...(includeDescriptions && { description: 'Second example file for ResourceLink demonstration' }) + } + ]; + + return { + content: [ + { + type: 'text', + text: 'Here are the available files as resource links:' + }, + ...resourceLinks, + { + type: 'text', + text: '\nYou can read any of these resources using their URI.' + } + ] + }; + } + ); + + // Register a long-running tool that demonstrates task execution + // Using the experimental tasks API - WARNING: may change without notice + server.experimental.tasks.registerToolTask( + 'delay', + { + title: 'Delay', + description: 'A simple tool that delays for a specified duration, useful for testing task execution', + inputSchema: z.object({ + duration: z.number().describe('Duration in milliseconds').default(5000) + }) + }, + { + async createTask({ duration }, ctx) { + // Create the task + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + // Simulate out-of-band work + (async () => { + await new Promise(resolve => setTimeout(resolve, duration)); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [ + { + type: 'text', + text: `Completed ${duration}ms delay` + } + ] + }); + })(); + + // Return CreateTaskResult with the created task + return { + task + }; + }, + async getTask(_args, ctx) { + return await ctx.task.store.getTask(ctx.task.id); + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + // Register a tool that demonstrates bidirectional task support: + // Server creates a task, then elicits input from client using elicitInputStream + // Using the experimental tasks API - WARNING: may change without notice + server.experimental.tasks.registerToolTask( + 'collect-user-info-task', + { + title: 'Collect Info with Task', + description: 'Collects user info via elicitation with task support using elicitInputStream', + inputSchema: z.object({ + infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact') + }) + }, + { + async createTask({ infoType }, ctx) { + // Create the server-side task + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + // Perform async work that makes a nested elicitation request using elicitInputStream + (async () => { + try { + const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences'; + + // Define schemas with proper typing for PrimitiveSchemaDefinition + const contactSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + name: { type: 'string', title: 'Full Name', description: 'Your full name' }, + email: { type: 'string', title: 'Email', description: 'Your email address' } + }, + required: ['name', 'email'] + }; + + const preferencesSchema: { + type: 'object'; + properties: Record; + required: string[]; + } = { + type: 'object', + properties: { + theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] }, + notifications: { type: 'boolean', title: 'Enable Notifications', default: true } + }, + required: ['theme'] + }; + + const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema; + + // Use elicitInputStream to elicit input from client + // This demonstrates the streaming elicitation API + // Access via server.server to get the underlying Server instance + const stream = server.server.experimental.tasks.elicitInputStream({ + mode: 'form', + message, + requestedSchema + }); + + let elicitResult: ElicitResult | undefined; + for await (const msg of stream) { + if (msg.type === 'result') { + elicitResult = msg.result as ElicitResult; + } else if (msg.type === 'error') { + throw msg.error; + } + } + + if (!elicitResult) { + throw new Error('No result received from elicitation'); + } + + let resultText: string; + if (elicitResult.action === 'accept') { + resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`; + } else if (elicitResult.action === 'decline') { + resultText = `User declined to provide ${infoType} information`; + } else { + resultText = 'User cancelled the request'; + } + + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: resultText }] + }); + } catch (error) { + console.error('Error in collect-user-info-task:', error); + await taskStore.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + return await ctx.task.store.getTask(ctx.task.id); + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + return server; +}; + +const MCP_PORT = process.env.MCP_PORT ? Number.parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? Number.parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; + +const app = createMcpExpressApp(); + +// Enable CORS for browser-based clients (demo only) +// This allows cross-origin requests and exposes WWW-Authenticate header for OAuth +// WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself. +app.use( + cors({ + exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Last-Event-Id', 'Mcp-Protocol-Version'], + origin: '*' // WARNING: This allows all origins to access the MCP server. In production, you should restrict this to specific origins. + }) +); + +// Set up OAuth if enabled +let authMiddleware = null; +if (useOAuth) { + // Create auth middleware for MCP endpoints + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); + const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); + + setupAuthServer({ authServerUrl, mcpServerUrl, demoMode: true, dangerousLoggingEnabled }); + + // Add protected resource metadata route to the MCP server + // This allows clients to discover the auth server + // Pass the resource path so metadata is served at /.well-known/oauth-protected-resource/mcp + app.use(createProtectedResourceMetadataRouter('/mcp')); + + authMiddleware = requireBearerAuth({ + verifier: demoTokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + }); +} + +// Map to store transports by session ID +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; + +// MCP POST endpoint with optional auth +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (sessionId) { + console.log(`Received MCP request for session: ${sessionId}`); + } else { + console.log('Request body:', req.body); + } + + if (useOAuth && req.auth) { + console.log('Authenticated user:', req.auth); + } + try { + let transport: NodeStreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + const eventStore = new InMemoryEventStore(); + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}, removing from transports map`); + delete transports[sid]; + } + }; + + // Connect the transport to the MCP server BEFORE handling the request + // so responses can flow back through the same transport + const server = getServer(); + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + // Handle the request with existing transport - no need to reconnect + // The existing transport is already connected to the server + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}; + +// Set up routes with conditional auth middleware +if (useOAuth && authMiddleware) { + app.post('/mcp', authMiddleware, mcpPostHandler); +} else { + app.post('/mcp', mcpPostHandler); +} + +// Handle GET requests for SSE streams (using built-in support from StreamableHTTP) +const mcpGetHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + if (useOAuth && req.auth) { + console.log('Authenticated SSE connection from user:', req.auth); + } + + // Check for Last-Event-ID header for resumability + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing new SSE stream for session ${sessionId}`); + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}; + +// Set up GET route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.get('/mcp', authMiddleware, mcpGetHandler); +} else { + app.get('/mcp', mcpGetHandler); +} + +// Handle DELETE requests for session termination (according to MCP spec) +const mcpDeleteHandler = async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}; + +// Set up DELETE route with conditional auth middleware +if (useOAuth && authMiddleware) { + app.delete('/mcp', authMiddleware, mcpDeleteHandler); +} else { + app.delete('/mcp', mcpDeleteHandler); +} + +app.listen(MCP_PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); + if (useOAuth) { + console.log(` Protected Resource Metadata: http://localhost:${MCP_PORT}/.well-known/oauth-protected-resource/mcp`); + } +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId]!.close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/examples/server/src/simpleTaskInteractive.ts b/examples/server/src/simpleTaskInteractive.ts new file mode 100644 index 0000000..fc0d728 --- /dev/null +++ b/examples/server/src/simpleTaskInteractive.ts @@ -0,0 +1,758 @@ +/** + * Simple interactive task server demonstrating elicitation and sampling. + * + * This server demonstrates the task message queue pattern from the MCP Tasks spec: + * - confirm_delete: Uses elicitation to ask the user for confirmation + * - write_haiku: Uses sampling to request an LLM to generate content + * + * Both tools use the "call-now, fetch-later" pattern where the initial call + * creates a task, and the result is fetched via tasks/result endpoint. + */ + +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { + CallToolResult, + CreateMessageRequest, + CreateMessageResult, + CreateTaskOptions, + CreateTaskResult, + ElicitRequestFormParams, + ElicitResult, + GetTaskPayloadResult, + GetTaskResult, + JSONRPCRequest, + PrimitiveSchemaDefinition, + QueuedMessage, + QueuedRequest, + RequestId, + Result, + SamplingMessage, + Task, + TaskMessageQueue, + TextContent, + Tool +} from '@modelcontextprotocol/server'; +import { InMemoryTaskStore, isTerminal, RELATED_TASK_META_KEY, Server } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; + +// ============================================================================ +// Resolver - Promise-like for passing results between async operations +// ============================================================================ + +class Resolver { + private _resolve!: (value: T) => void; + private _reject!: (error: Error) => void; + private _promise: Promise; + private _done = false; + + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + setResult(value: T): void { + if (this._done) return; + this._done = true; + this._resolve(value); + } + + setException(error: Error): void { + if (this._done) return; + this._done = true; + this._reject(error); + } + + wait(): Promise { + return this._promise; + } + + done(): boolean { + return this._done; + } +} + +// ============================================================================ +// Extended message queue with resolver support and wait functionality +// ============================================================================ + +interface QueuedRequestWithResolver extends QueuedRequest { + resolver?: Resolver>; + originalRequestId?: RequestId; +} + +type QueuedMessageWithResolver = QueuedRequestWithResolver | QueuedMessage; + +class TaskMessageQueueWithResolvers implements TaskMessageQueue { + private queues = new Map(); + private waitResolvers = new Map void)[]>(); + + private getQueue(taskId: string): QueuedMessageWithResolver[] { + let queue = this.queues.get(taskId); + if (!queue) { + queue = []; + this.queues.set(taskId, queue); + } + return queue; + } + + async enqueue(taskId: string, message: QueuedMessage, _sessionId?: string, maxSize?: number): Promise { + const queue = this.getQueue(taskId); + if (maxSize !== undefined && queue.length >= maxSize) { + throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`); + } + queue.push(message); + // Notify any waiters + this.notifyWaiters(taskId); + } + + async enqueueWithResolver( + taskId: string, + message: JSONRPCRequest, + resolver: Resolver>, + originalRequestId: RequestId + ): Promise { + const queue = this.getQueue(taskId); + const queuedMessage: QueuedRequestWithResolver = { + type: 'request', + message, + timestamp: Date.now(), + resolver, + originalRequestId + }; + queue.push(queuedMessage); + this.notifyWaiters(taskId); + } + + async dequeue(taskId: string, _sessionId?: string): Promise { + const queue = this.getQueue(taskId); + return queue.shift(); + } + + async dequeueAll(taskId: string, _sessionId?: string): Promise { + const queue = this.queues.get(taskId) ?? []; + this.queues.delete(taskId); + return queue; + } + + async waitForMessage(taskId: string): Promise { + // Check if there are already messages + const queue = this.getQueue(taskId); + if (queue.length > 0) return; + + // Wait for a message to be added + return new Promise(resolve => { + let waiters = this.waitResolvers.get(taskId); + if (!waiters) { + waiters = []; + this.waitResolvers.set(taskId, waiters); + } + waiters.push(resolve); + }); + } + + private notifyWaiters(taskId: string): void { + const waiters = this.waitResolvers.get(taskId); + if (waiters) { + this.waitResolvers.delete(taskId); + for (const resolve of waiters) { + resolve(); + } + } + } + + cleanup(): void { + this.queues.clear(); + this.waitResolvers.clear(); + } +} + +// ============================================================================ +// Extended task store with wait functionality +// ============================================================================ + +class TaskStoreWithNotifications extends InMemoryTaskStore { + private updateResolvers = new Map void)[]>(); + + override async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { + await super.updateTaskStatus(taskId, status, statusMessage, sessionId); + this.notifyUpdate(taskId); + } + + override async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { + await super.storeTaskResult(taskId, status, result, sessionId); + this.notifyUpdate(taskId); + } + + async waitForUpdate(taskId: string): Promise { + return new Promise(resolve => { + let waiters = this.updateResolvers.get(taskId); + if (!waiters) { + waiters = []; + this.updateResolvers.set(taskId, waiters); + } + waiters.push(resolve); + }); + } + + private notifyUpdate(taskId: string): void { + const waiters = this.updateResolvers.get(taskId); + if (waiters) { + this.updateResolvers.delete(taskId); + for (const resolve of waiters) { + resolve(); + } + } + } +} + +// ============================================================================ +// Task Result Handler - delivers queued messages and routes responses +// ============================================================================ + +class TaskResultHandler { + private pendingRequests = new Map>>(); + + constructor( + private store: TaskStoreWithNotifications, + private queue: TaskMessageQueueWithResolvers + ) {} + + async handle(taskId: string, server: Server, _sessionId: string): Promise { + while (true) { + // Get fresh task state + const task = await this.store.getTask(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + // Dequeue and send all pending messages + await this.deliverQueuedMessages(taskId, server, _sessionId); + + // If task is terminal, return result + if (isTerminal(task.status)) { + const result = await this.store.getTaskResult(taskId); + // Add related-task metadata per spec + return { + ...result, + _meta: { + ...result._meta, + [RELATED_TASK_META_KEY]: { taskId } + } + }; + } + + // Wait for task update or new message + await this.waitForUpdate(taskId); + } + } + + private async deliverQueuedMessages(taskId: string, server: Server, _sessionId: string): Promise { + while (true) { + const message = await this.queue.dequeue(taskId); + if (!message) break; + + console.log(`[Server] Delivering queued ${message.type} message for task ${taskId}`); + + if (message.type === 'request') { + const reqMessage = message as QueuedRequestWithResolver; + // Send the request via the server + // Store the resolver so we can route the response back + if (reqMessage.resolver && reqMessage.originalRequestId) { + this.pendingRequests.set(reqMessage.originalRequestId, reqMessage.resolver); + } + + // Send the message - for elicitation/sampling, we use the server's methods + // But since we're in tasks/result context, we need to send via transport + // This is simplified - in production you'd use proper message routing + try { + const request = reqMessage.message; + let response: ElicitResult | CreateMessageResult; + + if (request.method === 'elicitation/create') { + // Send elicitation request to client + const params = request.params as ElicitRequestFormParams; + response = await server.elicitInput(params); + } else if (request.method === 'sampling/createMessage') { + // Send sampling request to client + const params = request.params as CreateMessageRequest['params']; + response = await server.createMessage(params); + } else { + throw new Error(`Unknown request method: ${request.method}`); + } + + // Route response back to resolver + if (reqMessage.resolver) { + reqMessage.resolver.setResult(response as unknown as Record); + } + } catch (error) { + if (reqMessage.resolver) { + reqMessage.resolver.setException(error instanceof Error ? error : new Error(String(error))); + } + } + } + // For notifications, we'd send them too but this example focuses on requests + } + } + + private async waitForUpdate(taskId: string): Promise { + // Race between store update and queue message + await Promise.race([this.store.waitForUpdate(taskId), this.queue.waitForMessage(taskId)]); + } + + routeResponse(requestId: RequestId, response: Record): boolean { + const resolver = this.pendingRequests.get(requestId); + if (resolver && !resolver.done()) { + this.pendingRequests.delete(requestId); + resolver.setResult(response); + return true; + } + return false; + } + + routeError(requestId: RequestId, error: Error): boolean { + const resolver = this.pendingRequests.get(requestId); + if (resolver && !resolver.done()) { + this.pendingRequests.delete(requestId); + resolver.setException(error); + return true; + } + return false; + } +} + +// ============================================================================ +// Task Session - wraps server to enqueue requests during task execution +// ============================================================================ + +class TaskSession { + private requestCounter = 0; + + constructor( + private server: Server, + private taskId: string, + private store: TaskStoreWithNotifications, + private queue: TaskMessageQueueWithResolvers + ) {} + + private nextRequestId(): string { + return `task-${this.taskId}-${++this.requestCounter}`; + } + + async elicit( + message: string, + requestedSchema: { + type: 'object'; + properties: Record; + required?: string[]; + } + ): Promise<{ action: string; content?: Record }> { + // Update task status to input_required + await this.store.updateTaskStatus(this.taskId, 'input_required'); + + const requestId = this.nextRequestId(); + + // Build the elicitation request with related-task metadata + const params: ElicitRequestFormParams = { + message, + requestedSchema, + mode: 'form', + _meta: { + [RELATED_TASK_META_KEY]: { taskId: this.taskId } + } + }; + + const jsonrpcRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: requestId, + method: 'elicitation/create', + params + }; + + // Create resolver to wait for response + const resolver = new Resolver>(); + + // Enqueue the request + await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId); + + try { + // Wait for response + const response = await resolver.wait(); + + // Update status back to working + await this.store.updateTaskStatus(this.taskId, 'working'); + + return response as { action: string; content?: Record }; + } catch (error) { + await this.store.updateTaskStatus(this.taskId, 'working'); + throw error; + } + } + + async createMessage( + messages: SamplingMessage[], + maxTokens: number + ): Promise<{ role: string; content: TextContent | { type: string } }> { + // Update task status to input_required + await this.store.updateTaskStatus(this.taskId, 'input_required'); + + const requestId = this.nextRequestId(); + + // Build the sampling request with related-task metadata + const params = { + messages, + maxTokens, + _meta: { + [RELATED_TASK_META_KEY]: { taskId: this.taskId } + } + }; + + const jsonrpcRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: requestId, + method: 'sampling/createMessage', + params + }; + + // Create resolver to wait for response + const resolver = new Resolver>(); + + // Enqueue the request + await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId); + + try { + // Wait for response + const response = await resolver.wait(); + + // Update status back to working + await this.store.updateTaskStatus(this.taskId, 'working'); + + return response as { role: string; content: TextContent | { type: string } }; + } catch (error) { + await this.store.updateTaskStatus(this.taskId, 'working'); + throw error; + } + } +} + +// ============================================================================ +// Server Setup +// ============================================================================ + +const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8000; + +// Create shared stores +const taskStore = new TaskStoreWithNotifications(); +const messageQueue = new TaskMessageQueueWithResolvers(); +const taskResultHandler = new TaskResultHandler(taskStore, messageQueue); + +// Track active task executions +const activeTaskExecutions = new Map< + string, + { + promise: Promise; + server: Server; + sessionId: string; + } +>(); + +// Create the server +const createServer = (): Server => { + const server = new Server( + { name: 'simple-task-interactive', version: '1.0.0' }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { call: {} } + } + } + } + } + ); + + // Register tools + server.setRequestHandler('tools/list', async (): Promise<{ tools: Tool[] }> => { + return { + tools: [ + { + name: 'confirm_delete', + description: 'Asks for confirmation before deleting (demonstrates elicitation)', + inputSchema: { + type: 'object', + properties: { + filename: { type: 'string' } + } + }, + execution: { taskSupport: 'required' } + }, + { + name: 'write_haiku', + description: 'Asks LLM to write a haiku (demonstrates sampling)', + inputSchema: { + type: 'object', + properties: { + topic: { type: 'string' } + } + }, + execution: { taskSupport: 'required' } + } + ] + }; + }); + + // Handle tool calls + server.setRequestHandler('tools/call', async (request, ctx): Promise => { + const { name, arguments: args } = request.params; + const taskParams = (request.params._meta?.task || request.params.task) as { ttl?: number; pollInterval?: number } | undefined; + + // Validate task mode - these tools require tasks + if (!taskParams) { + throw new Error(`Tool ${name} requires task mode`); + } + + // Create task + const taskOptions: CreateTaskOptions = { + ttl: taskParams.ttl, + pollInterval: taskParams.pollInterval ?? 1000 + }; + + const task = await taskStore.createTask(taskOptions, ctx.mcpReq.id, request, ctx.sessionId); + + console.log(`\n[Server] ${name} called, task created: ${task.taskId}`); + + // Start background task execution + const taskExecution = (async () => { + try { + const taskSession = new TaskSession(server, task.taskId, taskStore, messageQueue); + + if (name === 'confirm_delete') { + const filename = args?.filename ?? 'unknown.txt'; + console.log(`[Server] confirm_delete: asking about '${filename}'`); + + console.log('[Server] Sending elicitation request to client...'); + const result = await taskSession.elicit(`Are you sure you want to delete '${filename}'?`, { + type: 'object', + properties: { + confirm: { type: 'boolean' } + }, + required: ['confirm'] + }); + + console.log( + `[Server] Received elicitation response: action=${result.action}, content=${JSON.stringify(result.content)}` + ); + + let text: string; + if (result.action === 'accept' && result.content) { + const confirmed = result.content.confirm; + text = confirmed ? `Deleted '${filename}'` : 'Deletion cancelled'; + } else { + text = 'Deletion cancelled'; + } + + console.log(`[Server] Completing task with result: ${text}`); + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text }] + }); + } else if (name === 'write_haiku') { + const topic = args?.topic ?? 'nature'; + console.log(`[Server] write_haiku: topic '${topic}'`); + + console.log('[Server] Sending sampling request to client...'); + const result = await taskSession.createMessage( + [ + { + role: 'user', + content: { type: 'text', text: `Write a haiku about ${topic}` } + } + ], + 50 + ); + + let haiku = 'No response'; + if (result.content && 'text' in result.content) { + haiku = (result.content as TextContent).text; + } + + console.log(`[Server] Received sampling response: ${haiku.slice(0, 50)}...`); + console.log('[Server] Completing task with haiku'); + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Haiku:\n${haiku}` }] + }); + } + } catch (error) { + console.error(`[Server] Task ${task.taskId} failed:`, error); + await taskStore.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } finally { + activeTaskExecutions.delete(task.taskId); + } + })(); + + activeTaskExecutions.set(task.taskId, { + promise: taskExecution, + server, + sessionId: ctx.sessionId ?? '' + }); + + return { task }; + }); + + // Handle tasks/get + server.setRequestHandler('tasks/get', async (request): Promise => { + const { taskId } = request.params; + const task = await taskStore.getTask(taskId); + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + return task; + }); + + // Handle tasks/result + server.setRequestHandler('tasks/result', async (request, ctx): Promise => { + const { taskId } = request.params; + console.log(`[Server] tasks/result called for task ${taskId}`); + return taskResultHandler.handle(taskId, server, ctx.sessionId ?? ''); + }); + + return server; +}; + +// ============================================================================ +// Express App Setup +// ============================================================================ + +const app = createMcpExpressApp(); + +// Map to store transports by session ID +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; + +// Helper to check if request is initialize +const isInitializeRequest = (body: unknown): boolean => { + return typeof body === 'object' && body !== null && 'method' in body && (body as { method: string }).method === 'initialize'; +}; + +// MCP POST endpoint +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sid => { + console.log(`Session initialized: ${sid}`); + transports[sid] = transport; + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Transport closed for session ${sid}`); + delete transports[sid]; + } + }; + + const server = createServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32_603, message: 'Internal server error' }, + id: null + }); + } + } +}); + +// Handle GET requests for SSE streams +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Handle DELETE requests for session termination +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Session termination request: ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Start server +app.listen(PORT, () => { + console.log(`Starting server on http://localhost:${PORT}/mcp`); + console.log('\nAvailable tools:'); + console.log(' - confirm_delete: Demonstrates elicitation (asks user y/n)'); + console.log(' - write_haiku: Demonstrates sampling (requests LLM completion)'); +}); + +// Handle shutdown +process.on('SIGINT', async () => { + console.log('\nShutting down server...'); + for (const sessionId of Object.keys(transports)) { + try { + await transports[sessionId]!.close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing session ${sessionId}:`, error); + } + } + taskStore.cleanup(); + messageQueue.cleanup(); + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/examples/server/src/ssePollingExample.ts b/examples/server/src/ssePollingExample.ts new file mode 100644 index 0000000..7c318d7 --- /dev/null +++ b/examples/server/src/ssePollingExample.ts @@ -0,0 +1,135 @@ +/** + * SSE Polling Example Server (SEP-1699) + * + * This example demonstrates server-initiated SSE stream disconnection + * and client reconnection with Last-Event-ID for resumability. + * + * Key features: + * - Configures `retryInterval` to tell clients how long to wait before reconnecting + * - Uses `eventStore` to persist events for replay after reconnection + * - Uses `ctx.http?.closeSSE()` callback to gracefully disconnect clients mid-operation + * + * Run with: pnpm tsx src/ssePollingExample.ts + * Test with: curl or the MCP Inspector + */ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; + +import { InMemoryEventStore } from './inMemoryEventStore.js'; + +// Create a fresh MCP server per client connection to avoid shared state between clients +const getServer = () => { + const server = new McpServer( + { + name: 'sse-polling-example', + version: '1.0.0' + }, + { + capabilities: { logging: {} } + } + ); + + // Register a long-running tool that demonstrates server-initiated disconnect + server.registerTool( + 'long-task', + { + description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.' + }, + async (ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${ctx.sessionId}] Starting long-task...`); + + // Send first progress notification + await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...'); + await sleep(1000); + + // Send second progress notification + await ctx.mcpReq.log('info', 'Progress: 50% - Halfway there...'); + await sleep(1000); + + // Server decides to disconnect the client to free resources + // Client will reconnect via GET with Last-Event-ID after the transport's retryInterval + // Use ctx.http?.closeSSE callback - available when eventStore is configured + if (ctx.http?.closeSSE) { + console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); + ctx.http?.closeSSE(); + } + + // Continue processing while client is disconnected + // Events are stored in eventStore and will be replayed on reconnect + await sleep(500); + await ctx.mcpReq.log('info', 'Progress: 75% - Almost done (sent while client disconnected)...'); + + await sleep(500); + await ctx.mcpReq.log('info', 'Progress: 100% - Complete!'); + + console.log(`[${ctx.sessionId}] Task complete`); + + return { + content: [ + { + type: 'text', + text: 'Long task completed successfully!' + } + ] + }; + } + ); + + return server; +}; + +// Set up Express app +const app = createMcpExpressApp(); +app.use(cors()); + +// Create event store for resumability +const eventStore = new InMemoryEventStore(); + +// Track transports by session ID for session reuse +const transports = new Map(); + +// Handle all MCP requests +app.all('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + // Reuse existing transport or create new one + let transport = sessionId ? transports.get(sessionId) : undefined; + + if (!transport) { + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + retryInterval: 2000, // Default retry interval for priming events + onsessioninitialized: id => { + console.log(`[${id}] Session initialized`); + transports.set(id, transport!); + } + }); + + // Connect a fresh MCP server to the transport + const server = getServer(); + await server.connect(transport); + } + + await transport.handleRequest(req, res, req.body); +}); + +// Start the server +const PORT = 3001; +app.listen(PORT, () => { + console.log(`SSE Polling Example Server running on http://localhost:${PORT}/mcp`); + console.log(''); + console.log('This server demonstrates SEP-1699 SSE polling:'); + console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)'); + console.log('- eventStore: InMemoryEventStore (events are persisted for replay)'); + console.log(''); + console.log('Try calling the "long-task" tool to see server-initiated disconnect in action.'); +}); diff --git a/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/examples/server/src/standaloneSseWithGetStreamableHttp.ts new file mode 100644 index 0000000..7e133f6 --- /dev/null +++ b/examples/server/src/standaloneSseWithGetStreamableHttp.ts @@ -0,0 +1,168 @@ +import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { ReadResourceResult } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; + +// Helper to register a dynamic resource on a given server instance +const addResource = (server: McpServer, name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.registerResource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }] + }; + } + ); +}; + +// Create a fresh MCP server per client connection to avoid shared state between clients +const getServer = () => { + const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0' + }); + + addResource(server, 'example-resource', 'Initial content for example-resource'); + + return server; +}; + +// Store transports and their associated servers by session ID +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// Periodically add a new resource to all active server instances for testing +const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + for (const sessionId in servers) { + addResource(servers[sessionId]!, name, `Content for ${name}`); + } +}, 5000); // Change resources every 5 seconds for testing + +const app = createMcpExpressApp(); + +app.post('/mcp', async (req: Request, res: Response) => { + console.log('Received MCP request:', req.body); + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request - create a fresh server for this client + const server = getServer(); + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sessionId => { + // Store the transport and server by session ID when session is initialized + // This avoids race conditions where requests might come in before the session is stored + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + servers[sessionId] = server; + } + }); + + // Clean up both maps when the transport closes + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) { + delete transports[sid]; + delete servers[sid]; + } + }; + + // Connect the fresh MCP server to the transport + await server.connect(transport); + + // Handle the request - the onsessioninitialized callback will store the transport + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + // Handle the request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests for SSE streams (now using built-in support from StreamableHTTP) +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + clearInterval(resourceChangeInterval); + + // Close all active transports to properly clean up resources + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId]!.close(); + delete transports[sessionId]; + delete servers[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/examples/server/src/toolWithSampleServer.ts b/examples/server/src/toolWithSampleServer.ts new file mode 100644 index 0000000..f6b053c --- /dev/null +++ b/examples/server/src/toolWithSampleServer.ts @@ -0,0 +1,60 @@ +// Run with: pnpm tsx src/toolWithSampleServer.ts + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import * as z from 'zod/v4'; + +const mcpServer = new McpServer({ + name: 'tools-with-sample-server', + version: '1.0.0' +}); + +// Tool that uses LLM sampling to summarize any text +mcpServer.registerTool( + 'summarize', + { + description: 'Summarize any text using an LLM', + inputSchema: z.object({ + text: z.string().describe('Text to summarize') + }) + }, + async ({ text }) => { + // Call the LLM through MCP sampling + const response = await mcpServer.server.createMessage({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please summarize the following text concisely:\n\n${text}` + } + } + ], + maxTokens: 500 + }); + + // Since we're not passing tools param to createMessage, response.content is single content + return { + content: [ + { + type: 'text', + text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' + } + ] + }; + } +); + +async function main() { + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + console.log('MCP server is running...'); +} + +try { + await main(); +} catch (error) { + console.error('Server error:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); +} diff --git a/examples/server/src/valibotExample.ts b/examples/server/src/valibotExample.ts new file mode 100644 index 0000000..8d92bf1 --- /dev/null +++ b/examples/server/src/valibotExample.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Minimal MCP server using Valibot for schema validation. + * Use toStandardJsonSchema() from @valibot/to-json-schema to create + * StandardJSONSchemaV1-compliant schemas. + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import * as v from 'valibot'; + +const server = new McpServer({ + name: 'valibot-example', + version: '1.0.0' +}); + +// Register a tool with Valibot schema +server.registerTool( + 'greet', + { + description: 'Generate a greeting', + inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/examples/server/tsconfig.json b/examples/server/tsconfig.json new file mode 100644 index 0000000..37a3e87 --- /dev/null +++ b/examples/server/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/hono": ["./node_modules/@modelcontextprotocol/hono/src/index.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ], + "@modelcontextprotocol/examples-shared": ["./node_modules/@modelcontextprotocol/examples-shared/src/index.ts"], + "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/examples/server/tsdown.config.ts b/examples/server/tsdown.config.ts new file mode 100644 index 0000000..efc4299 --- /dev/null +++ b/examples/server/tsdown.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + // 1. Entry Points + // Directly matches package.json include/exclude globs + entry: ['src/**/*.ts'], + + // 2. Output Configuration + format: ['esm'], + outDir: 'dist', + clean: true, // Recommended: Cleans 'dist' before building + sourcemap: true, + + // 3. Platform & Target + target: 'esnext', + platform: 'node', + shims: true, // Polyfills common Node.js shims (__dirname, etc.) + + // 4. Type Definitions + // Bundles d.ts files into a single output + dts: false, + // 5. Vendoring Strategy - Bundle the code for this specific package into the output, + // but treat all other dependencies as external (require/import). + noExternal: ['@modelcontextprotocol/examples-shared'] +}); diff --git a/examples/server/vitest.config.js b/examples/server/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/examples/server/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/examples/shared/eslint.config.mjs b/examples/shared/eslint.config.mjs new file mode 100644 index 0000000..83b7987 --- /dev/null +++ b/examples/shared/eslint.config.mjs @@ -0,0 +1,14 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Allow console statements in examples only + 'no-console': 'off' + } + } +]; diff --git a/examples/shared/package.json b/examples/shared/package.json new file mode 100644 index 0000000..0bab8be --- /dev/null +++ b/examples/shared/package.json @@ -0,0 +1,62 @@ +{ + "name": "@modelcontextprotocol/examples-shared", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "prepack": "pnpm run build:esm && pnpm run build:cjs", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "start": "pnpm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", + "better-auth": "^1.4.17", + "better-sqlite3": "^12.6.2", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/better-sqlite3": "^7.6.13", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/examples/shared/src/auth.ts b/examples/shared/src/auth.ts new file mode 100644 index 0000000..7ba4ddd --- /dev/null +++ b/examples/shared/src/auth.ts @@ -0,0 +1,250 @@ +/** + * Better Auth configuration for MCP demo servers + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * This configuration uses in-memory SQLite and auto-approves all logins. + * For production use, configure a proper database and authentication flow. + */ + +import { randomBytes } from 'node:crypto'; + +import type { BetterAuthOptions } from 'better-auth'; +import { betterAuth } from 'better-auth'; +import { mcp } from 'better-auth/plugins'; +import Database from 'better-sqlite3'; + +// Generate a random password for the demo user (new each time the server starts) +const DEMO_PASSWORD = randomBytes(16).toString('base64url'); + +// Create the in-memory database once (module-level singleton) +// This avoids the type export issue and ensures the same DB is used +let _db: InstanceType | null = null; + +function getDatabase(): InstanceType { + if (!_db) { + _db = new Database(':memory:'); + initializeSchema(_db); + } + return _db; +} + +/** + * Initialize the database schema for better-auth + MCP plugin. + * This creates all required tables for the demo. + * + * Schema based on: + * - https://www.better-auth.com/docs/concepts/database#core-schema + * - https://www.better-auth.com/docs/plugins/oidc-provider#schema + */ +function initializeSchema(db: InstanceType): void { + // Core better-auth tables + db.exec(` + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL DEFAULT 0, + image TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + expiresAt TEXT NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL REFERENCES user(id), + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS account ( + id TEXT PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL REFERENCES user(id), + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt TEXT, + refreshTokenExpiresAt TEXT, + scope TEXT, + password TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt TEXT NOT NULL, + createdAt TEXT, + updatedAt TEXT + ); + `); + + // OIDC/MCP plugin tables + db.exec(` + CREATE TABLE IF NOT EXISTS oauthApplication ( + id TEXT PRIMARY KEY, + name TEXT, + icon TEXT, + metadata TEXT, + clientId TEXT NOT NULL UNIQUE, + clientSecret TEXT, + redirectUrls TEXT NOT NULL, + type TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0, + userId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oauthAccessToken ( + id TEXT PRIMARY KEY, + accessToken TEXT NOT NULL UNIQUE, + refreshToken TEXT UNIQUE, + accessTokenExpiresAt TEXT NOT NULL, + refreshTokenExpiresAt TEXT, + clientId TEXT NOT NULL, + userId TEXT, + scopes TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oauthRefreshToken ( + id TEXT PRIMARY KEY, + refreshToken TEXT NOT NULL UNIQUE, + accessTokenId TEXT NOT NULL, + expiresAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oauthAuthorizationCode ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + clientId TEXT NOT NULL, + userId TEXT, + scopes TEXT NOT NULL, + redirectURI TEXT NOT NULL, + codeChallenge TEXT, + codeChallengeMethod TEXT, + expiresAt TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oauthConsent ( + id TEXT PRIMARY KEY, + clientId TEXT NOT NULL, + userId TEXT NOT NULL, + scopes TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + consentGiven INTEGER NOT NULL DEFAULT 0 + ); + `); + + console.log('[Auth] In-memory database schema initialized'); + console.log('[Auth] ========================================'); + console.log('[Auth] Demo user credentials (auto-login):'); + console.log(`[Auth] Email: ${DEMO_USER_CREDENTIALS.email}`); + console.log(`[Auth] Password: ${DEMO_USER_CREDENTIALS.password}`); + console.log('[Auth] ========================================'); +} + +/** + * Demo user credentials for auto-login. + * Password is randomly generated each time the server starts. + * Used by authServer.ts to create and sign in the demo user. + */ +export const DEMO_USER_CREDENTIALS = { + email: 'demo@example.com', + password: DEMO_PASSWORD, + name: 'Demo User' +}; + +export interface CreateDemoAuthOptions { + baseURL: string; + resource?: string; + loginPage?: string; + demoMode: boolean; +} + +/** + * Creates a better-auth instance configured for MCP OAuth demo. + * + * @param options - Configuration options + * @param options.baseURL - The base URL for the auth server (e.g., http://localhost:3001) + * @param options.resource - The MCP resource server URL (for protected resource metadata) + * @param options.loginPage - Path to login page (defaults to /sign-in) + * + * @see https://www.better-auth.com/docs/plugins/mcp + */ +export function createDemoAuth(options: CreateDemoAuthOptions) { + const { baseURL, resource, loginPage = '/sign-in', demoMode } = options; + + // Use in-memory SQLite database for demo purposes + // Note: All data is lost on restart - demo only! + const db = getDatabase(); + + // MCP plugin configuration + const mcpPlugin = mcp({ + loginPage, + resource, + oidcConfig: { + loginPage, + codeExpiresIn: 600, // 10 minutes + accessTokenExpiresIn: 3600, // 1 hour + refreshTokenExpiresIn: 604_800, // 7 days + defaultScope: 'openid', + scopes: ['openid', 'profile', 'email', 'offline_access'], + allowDynamicClientRegistration: true, + metadata: { + scopes_supported: ['openid', 'profile', 'email', 'offline_access'] + } + } + }); + + return betterAuth({ + baseURL, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + database: db as any, // Type cast to avoid exposing better-sqlite3 in exported types + trustedOrigins: [baseURL.toString()], + // Basic email+password for demo + emailAndPassword: { + enabled: true, + requireEmailVerification: false + }, + plugins: [mcpPlugin], + // Enable verbose logging for demo/debugging + logger: demoMode + ? { + disabled: false, + level: 'debug', + log: (level, message, ...args) => { + const timestamp = new Date().toISOString(); + const prefix = `[Auth ${level.toUpperCase()}]`; + if (args.length > 0) { + console.log(`${timestamp} ${prefix} ${message}`, ...args); + } else { + console.log(`${timestamp} ${prefix} ${message}`); + } + } + } + : undefined + } satisfies BetterAuthOptions); +} + +/** + * Type for the auth instance returned by createDemoAuth. + * Note: Due to plugin type inference complexity, we use a generic type. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DemoAuth = ReturnType; diff --git a/examples/shared/src/authServer.ts b/examples/shared/src/authServer.ts new file mode 100644 index 0000000..995fedc --- /dev/null +++ b/examples/shared/src/authServer.ts @@ -0,0 +1,314 @@ +/** + * Better Auth Server Setup for MCP Demo + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * This creates a standalone OAuth Authorization Server using better-auth + * that MCP clients can use to obtain access tokens. + * + * See: https://www.better-auth.com/docs/plugins/mcp + */ + +import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import { toNodeHandler } from 'better-auth/node'; +import { oAuthDiscoveryMetadata, oAuthProtectedResourceMetadata } from 'better-auth/plugins'; +import cors from 'cors'; +import type { Request, Response as ExpressResponse, Router } from 'express'; +import express from 'express'; + +import type { DemoAuth } from './auth.js'; +import { createDemoAuth, DEMO_USER_CREDENTIALS } from './auth.js'; + +export interface SetupAuthServerOptions { + authServerUrl: URL; + mcpServerUrl: URL; + /** + * Examples should be used for **demo** only and not for production purposes, however this mode disables some logging and other features. + */ + demoMode: boolean; + /** + * Enable verbose logging of better-auth requests/responses. + * WARNING: This may log sensitive information like tokens and cookies. + * Only use for debugging purposes. + */ + dangerousLoggingEnabled?: boolean; +} + +// Store auth instance globally so it can be used for token verification +let globalAuth: DemoAuth | null = null; +let demoUserCreated = false; + +/** + * Gets the global auth instance (must call setupAuthServer first) + */ +export function getAuth(): DemoAuth { + if (!globalAuth) { + throw new Error('Auth not initialized. Call setupAuthServer first.'); + } + return globalAuth; +} + +/** + * Ensures the demo user exists by calling signUpEmail (creates user with proper password hash) + * Returns true if successful, false if user already exists (which is fine) + */ +async function ensureDemoUserExists(auth: DemoAuth): Promise { + if (demoUserCreated) return; + + try { + // Try to sign up the demo user + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (auth.api as any).signUpEmail({ + body: { + email: DEMO_USER_CREDENTIALS.email, + password: DEMO_USER_CREDENTIALS.password, + name: DEMO_USER_CREDENTIALS.name + } + }); + console.log('[Auth] Demo user created via signUpEmail'); + demoUserCreated = true; + } catch (error) { + // User might already exist, which is fine + const message = error instanceof Error ? error.message : String(error); + if (message.includes('already') || message.includes('exists') || message.includes('unique')) { + console.log('[Auth] Demo user already exists'); + demoUserCreated = true; + } else { + console.error('[Auth] Failed to create demo user:', error); + throw error; + } + } +} + +/** + * Sets up and starts the OAuth Authorization Server on a separate port. + * + * @param options - Server configuration + */ +export function setupAuthServer(options: SetupAuthServerOptions): void { + const { authServerUrl, mcpServerUrl, demoMode, dangerousLoggingEnabled = false } = options; + + // Create better-auth instance with MCP plugin + const auth = createDemoAuth({ + baseURL: authServerUrl.toString().replace(/\/$/, ''), + resource: mcpServerUrl.toString(), + loginPage: '/sign-in', + demoMode: demoMode + }); + + // Store globally for token verification + globalAuth = auth; + + // Create Express app for auth server + const authApp = express(); + + // Enable CORS for all origins (demo only) - must be before other middleware + // WARNING: This configuration is for demo purposes only. In production, you should restrict this to specific origins and configure CORS yourself. + authApp.use( + cors({ + origin: '*' // WARNING: This allows all origins to access the auth server. In production, you should restrict this to specific origins. + }) + ); + + // Create better-auth handler + // toNodeHandler bypasses Express methods + const betterAuthHandler = toNodeHandler(auth); + + // Mount better-auth handler BEFORE body parsers + // toNodeHandler reads the raw request body, so Express must not consume it first + if (dangerousLoggingEnabled) { + // Verbose logging mode - intercept at Node.js level to see all requests/responses + // WARNING: This may log sensitive information like tokens and cookies + authApp.all('/api/auth/{*splat}', (req, res) => { + const ts = new Date().toISOString(); + console.log(`\n${'='.repeat(60)}`); + console.log(`${ts} [AUTH] ${req.method} ${req.originalUrl}`); + console.log(`${ts} [AUTH] Query:`, JSON.stringify(req.query)); + console.log(`${ts} [AUTH] Headers.Cookie:`, req.headers.cookie?.slice(0, 100)); + + // Intercept writeHead to capture status and headers (including redirects) + const originalWriteHead = res.writeHead.bind(res); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.writeHead = function (statusCode: number, ...args: any[]) { + console.log(`${ts} [AUTH] >>> Response Status: ${statusCode}`); + // Headers can be in different positions depending on the overload + const headers = args.find(a => typeof a === 'object' && a !== null); + if (headers) { + if (headers.location || headers.Location) { + console.log(`${ts} [AUTH] >>> Location (redirect): ${headers.location || headers.Location}`); + } + console.log(`${ts} [AUTH] >>> Headers:`, JSON.stringify(headers)); + } + return originalWriteHead(statusCode, ...args); + }; + + // Intercept write to capture response body + const originalWrite = res.write.bind(res); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + res.write = function (chunk: any, ...args: any[]) { + if (chunk) { + const bodyPreview = typeof chunk === 'string' ? chunk.slice(0, 500) : chunk.toString().slice(0, 500); + console.log(`${ts} [AUTH] >>> Body: ${bodyPreview}`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return originalWrite(chunk, ...(args as [any])); + }; + + return betterAuthHandler(req, res); + }); + } else { + // Normal mode - no verbose logging + authApp.all('/api/auth/{*splat}', toNodeHandler(auth)); + } + + // OAuth metadata endpoints using better-auth's built-in handlers + // Add explicit OPTIONS handler for CORS preflight + authApp.options('/.well-known/oauth-authorization-server', cors()); + authApp.get('/.well-known/oauth-authorization-server', cors(), toNodeHandler(oAuthDiscoveryMetadata(auth))); + + // Body parsers for non-better-auth routes (like /sign-in) + authApp.use(express.json()); + authApp.use(express.urlencoded({ extended: true })); + + // Auto-login page that creates a real better-auth session + // This simulates a user logging in and approving the OAuth request + authApp.get('/sign-in', async (req: Request, res: ExpressResponse) => { + // Get the OAuth authorization parameters from the query string + const queryParams = new URLSearchParams(req.query as Record); + const redirectUri = queryParams.get('redirect_uri'); + const clientId = queryParams.get('client_id'); + + if (!redirectUri || !clientId) { + res.status(400).send(` + + + Demo Login + +

Demo OAuth Server

+

Missing required OAuth parameters. This page should be accessed via OAuth flow.

+ + + `); + return; + } + + try { + // Ensure demo user exists (creates with proper password hash) + await ensureDemoUserExists(auth); + + // Create a session using better-auth's signIn API with asResponse to get Set-Cookie headers + const signInResponse = await auth.api.signInEmail({ + body: { + email: DEMO_USER_CREDENTIALS.email, + password: DEMO_USER_CREDENTIALS.password + }, + asResponse: true + }); + + console.log('[Auth] Sign-in response status:', signInResponse.status); + + // Forward all Set-Cookie headers from better-auth's response + const setCookieHeaders = signInResponse.headers.getSetCookie(); + console.log('[Auth] Set-Cookie headers:', setCookieHeaders); + + for (const cookie of setCookieHeaders) { + res.append('Set-Cookie', cookie); + } + + console.log(`[Auth Server] Session created, redirecting to authorize`); + + // Redirect to the authorization endpoint + const authorizeUrl = new URL('/api/auth/mcp/authorize', authServerUrl); + authorizeUrl.search = queryParams.toString(); + + res.redirect(authorizeUrl.toString()); + } catch (error) { + console.error('[Auth Server] Failed to create session:', error); + res.status(500).send(` + + + Demo Login Error + +

Demo OAuth Server - Error

+

Failed to create demo session: ${error instanceof Error ? error.message : 'Unknown error'}

+
${error instanceof Error ? error.stack : ''}
+ + + `); + } + }); + + // Start the auth server + const authPort = Number.parseInt(authServerUrl.port, 10); + authApp.listen(authPort, (error?: Error) => { + if (error) { + console.error('Failed to start auth server:', error); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(1); + } + console.log(`OAuth Authorization Server listening on port ${authPort}`); + console.log(` Authorization: ${authServerUrl}api/auth/mcp/authorize`); + console.log(` Token: ${authServerUrl}api/auth/mcp/token`); + console.log(` Metadata: ${authServerUrl}.well-known/oauth-authorization-server`); + }); +} + +/** + * Creates an Express router that serves OAuth Protected Resource Metadata + * on the MCP server using better-auth's built-in handler. + * + * This is needed because MCP clients discover the auth server by first + * fetching protected resource metadata from the MCP server. + * + * Per RFC 9728 Section 3, the metadata URL includes the resource path. + * E.g., for resource http://localhost:3000/mcp, metadata is at + * http://localhost:3000/.well-known/oauth-protected-resource/mcp + * + * See: https://www.better-auth.com/docs/plugins/mcp#oauth-protected-resource-metadata + * + * @param resourcePath - The path of the MCP resource (e.g., '/mcp'). Defaults to '/mcp'. + */ +export function createProtectedResourceMetadataRouter(resourcePath = '/mcp'): Router { + const auth = getAuth(); + const router = express.Router(); + + // Construct the metadata path per RFC 9728 Section 3 + const metadataPath = `/.well-known/oauth-protected-resource${resourcePath}`; + + // Enable CORS for browser-based clients to discover the auth server + // Add explicit OPTIONS handler for CORS preflight + router.options(metadataPath, cors()); + router.get(metadataPath, cors(), toNodeHandler(oAuthProtectedResourceMetadata(auth))); + + return router; +} + +/** + * Demo {@link OAuthTokenVerifier} backed by better-auth's `getMcpSession`. + * Pass this to `requireBearerAuth({ verifier: demoTokenVerifier, ... })` from + * `@modelcontextprotocol/express` to validate Bearer tokens against the demo + * Authorization Server started by `setupAuthServer`. + */ +export const demoTokenVerifier: OAuthTokenVerifier = { + async verifyAccessToken(token: string): Promise { + const auth = getAuth(); + + const headers = new Headers(); + headers.set('Authorization', `Bearer ${token}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = await (auth.api as any).getMcpSession({ headers }); + if (!session) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Invalid token'); + } + + const scopes = typeof session.scopes === 'string' ? session.scopes.split(' ') : ['openid']; + const expiresAt = session.accessTokenExpiresAt + ? Math.floor(new Date(session.accessTokenExpiresAt).getTime() / 1000) + : Math.floor(Date.now() / 1000) + 3600; + + return { token, clientId: session.clientId, scopes, expiresAt }; + } +}; diff --git a/examples/shared/src/index.ts b/examples/shared/src/index.ts new file mode 100644 index 0000000..47c4d67 --- /dev/null +++ b/examples/shared/src/index.ts @@ -0,0 +1,7 @@ +// Auth configuration +export type { CreateDemoAuthOptions, DemoAuth } from './auth.js'; +export { createDemoAuth } from './auth.js'; + +// Auth server setup + demo token verifier (pass to `requireBearerAuth` from @modelcontextprotocol/express) +export type { SetupAuthServerOptions } from './authServer.js'; +export { createProtectedResourceMetadataRouter, demoTokenVerifier, getAuth, setupAuthServer } from './authServer.js'; diff --git a/examples/shared/test/demoInMemoryOAuthProvider.test.ts b/examples/shared/test/demoInMemoryOAuthProvider.test.ts new file mode 100644 index 0000000..bd3131d --- /dev/null +++ b/examples/shared/test/demoInMemoryOAuthProvider.test.ts @@ -0,0 +1,37 @@ +/** + * Tests for the demo OAuth provider using better-auth + * + * DEMO ONLY - NOT FOR PRODUCTION + * + * The demo OAuth provider now uses better-auth with the MCP plugin. + * These tests verify the basic setup works correctly. + */ + +import { describe, expect, it } from 'vitest'; + +import type { CreateDemoAuthOptions } from '../src/auth.js'; +import { createDemoAuth } from '../src/auth.js'; + +describe('createDemoAuth', () => { + const validOptions: CreateDemoAuthOptions = { + baseURL: 'http://localhost:3001', + resource: 'http://localhost:3000/mcp', + loginPage: '/sign-in', + demoMode: true + }; + + it('creates a better-auth instance with MCP plugin', () => { + const auth = createDemoAuth(validOptions); + expect(auth).toBeDefined(); + expect(auth.api).toBeDefined(); + }); + + it('uses default loginPage when not specified', () => { + const options: CreateDemoAuthOptions = { + baseURL: 'http://localhost:3001', + demoMode: true + }; + const auth = createDemoAuth(options); + expect(auth).toBeDefined(); + }); +}); diff --git a/examples/shared/tsconfig.json b/examples/shared/tsconfig.json new file mode 100644 index 0000000..bfc4eab --- /dev/null +++ b/examples/shared/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ], + "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], + "@modelcontextprotocol/client": [ + "./node_modules/@modelcontextprotocol/test-helpers/node_modules/@modelcontextprotocol/client/src/index.ts" + ] + } + } +} diff --git a/examples/shared/vitest.config.js b/examples/shared/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/examples/shared/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/lefthook-local.example.yml b/lefthook-local.example.yml new file mode 100644 index 0000000..3c56ce5 --- /dev/null +++ b/lefthook-local.example.yml @@ -0,0 +1,23 @@ +# Optional local lefthook configuration +# To enable this: +# cp lefthook-local.example.yml lefthook-local.yml + +pre-commit: + parallel: true + jobs: + - name: 'Typecheck' + run: pnpm typecheck:all + + - name: 'Lint Fix & Format' + run: pnpm lint:fix:all + stage_fixed: true + +post-checkout: + jobs: + - name: 'Install Dependencies' + run: pnpm install + +post-merge: + jobs: + - name: 'Install Dependencies' + run: pnpm install diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..1a2d97a --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,37 @@ +# lefthook.yml +# Configuration reference: https://lefthook.dev/configuration/ + +assert_lefthook_installed: true + +output: + - meta # Print lefthook version + - summary # Print summary block (successful and failed steps) + - empty_summary # Print summary heading when there are no steps to run + - success # Print successful steps + - failure # Print failed steps printing + - execution # Print any execution logs (but prints if the execution failed) + - execution_out # Print execution output (but still prints failed commands output) + - execution_info # Print `EXECUTE > ...` logging + - skips # Print "skip" (i.e. no files matched) + +pre-push: + follow: true + parallel: true + jobs: + - name: 'Typecheck' + run: pnpm run typecheck:all + fail_text: | + 💡 To catch typechecking issues earlier, enable the pre-commit hook: + cp lefthook-local.example.yml lefthook-local.yml + + - name: 'Lint' + run: pnpm run lint:all + fail_text: | + 💡 To catch linting issues earlier, enable the pre-commit hook: + cp lefthook-local.example.yml lefthook-local.yml + + - name: 'Build' + run: pnpm run build:all + fail_text: | + 💡 To catch build issues earlier, enable the pre-commit hook: + cp lefthook-local.example.yml lefthook-local.yml diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2cb93f --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "@modelcontextprotocol/sdk", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "SEE LICENSE IN LICENSE", + "author": "Model Context Protocol a Series of LF Projects, LLC.", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@10.26.1", + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "sync:snippets": "tsx scripts/sync-snippets.ts", + "examples:simple-server:w": "pnpm --filter @modelcontextprotocol/examples-server exec tsx --watch src/simpleStreamableHttp.ts --oauth", + "docs": "typedoc", + "docs:multi": "bash scripts/generate-multidoc.sh", + "docs:check": "typedoc", + "typecheck:all": "pnpm -r typecheck", + "build:all": "pnpm -r build", + "ci:publish": "pnpm run build:all && pnpm changeset publish", + "prepack:all": "pnpm -r prepack", + "lint:all": "pnpm sync:snippets --check && pnpm -r lint", + "lint:fix:all": "pnpm sync:snippets && pnpm -r lint:fix", + "check:all": "pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", + "test:all": "pnpm -r test", + "prepare": "npx --no-install lefthook install", + "test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client", + "test:conformance:client:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:all", + "test:conformance:client:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client:run", + "test:conformance:server": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server", + "test:conformance:server:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:all", + "test:conformance:server:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:run", + "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" + }, + "devDependencies": { + "lefthook": "^2.0.16", + "@cfworker/json-schema": "catalog:runtimeShared", + "@changesets/changelog-github": "^0.5.2", + "@changesets/cli": "^2.29.8", + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@types/content-type": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/node": "^24.10.1", + "@types/supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "fast-glob": "^3.3.3", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "tslib": "^2.8.1", + "tsx": "catalog:devTools", + "typedoc": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools", + "zod": "catalog:runtimeShared" + }, + "resolutions": { + "strip-ansi": "6.0.1" + } +} diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md new file mode 100644 index 0000000..bc024d9 --- /dev/null +++ b/packages/client/CHANGELOG.md @@ -0,0 +1,151 @@ +# @modelcontextprotocol/client + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +## 2.0.0-alpha.1 + +### Major Changes + +- [#1783](https://github.com/modelcontextprotocol/typescript-sdk/pull/1783) [`045c62a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/045c62a1e0ada756afe90dd1442534e362269dbf) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Remove + `WebSocketClientTransport`. WebSocket is not a spec-defined transport; use stdio or Streamable HTTP. The `Transport` interface remains exported for custom implementations. See #142. + +### Minor Changes + +- [#1527](https://github.com/modelcontextprotocol/typescript-sdk/pull/1527) [`dc896e1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dc896e198bdd1367d93a7c38846fdf9e78d84c6a) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add + `discoverOAuthServerInfo()` function and unified discovery state caching for OAuth + - New `discoverOAuthServerInfo(serverUrl)` export that performs RFC 9728 protected resource metadata discovery followed by authorization server metadata discovery in a single call. Use this for operations like token refresh and revocation that need the authorization server + URL outside of `auth()`. + - New `OAuthDiscoveryState` type and optional `OAuthClientProvider` methods `saveDiscoveryState()` / `discoveryState()` allow providers to persist all discovery results (auth server URL, resource metadata URL, resource metadata, auth server metadata) across sessions. This + avoids redundant discovery requests and handles browser redirect scenarios where discovery state would otherwise be lost. + - New `'discovery'` scope for `invalidateCredentials()` to clear cached discovery state. + - New `OAuthServerInfo` type exported for the return value of `discoverOAuthServerInfo()`. + +- [#1673](https://github.com/modelcontextprotocol/typescript-sdk/pull/1673) [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - refactor: extract task + orchestration from Protocol into TaskManager + + **Breaking changes:** + - `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` + +- [#1763](https://github.com/modelcontextprotocol/typescript-sdk/pull/1763) [`6711ed9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6711ed9ae8a6a98f415aaa4f145941a562b8e191) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add + `reconnectionScheduler` option to `StreamableHTTPClientTransport`. Lets non-persistent environments (serverless, mobile, desktop sleep/wake) override the default `setTimeout`-based SSE reconnection scheduling. The scheduler may return a cancel function that is invoked on + `transport.close()`. + +- [#1443](https://github.com/modelcontextprotocol/typescript-sdk/pull/1443) [`4aec5f7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4aec5f790624b1931cf62c006ae02b09d7562d2f) Thanks [@NSeydoux](https://github.com/NSeydoux)! - The client credentials providers now + support scopes being added to the token request. + +- [#1689](https://github.com/modelcontextprotocol/typescript-sdk/pull/1689) [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Support Standard Schema + for tool and prompt schemas + + Tool and prompt registration now accepts any schema library that implements the [Standard Schema spec](https://standardschema.dev/): Zod v4, Valibot, ArkType, and others. `RegisteredTool.inputSchema`, `RegisteredTool.outputSchema`, and `RegisteredPrompt.argsSchema` now use + `StandardSchemaWithJSON` (requires both `~standard.validate` and `~standard.jsonSchema`) instead of the Zod-specific `AnySchema` type. + + **Zod v4 schemas continue to work unchanged** — Zod v4 implements the required interfaces natively. + + ```typescript + import { type } from 'arktype'; + + server.registerTool( + 'greet', + { + inputSchema: type({ name: 'string' }) + }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + ``` + + For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter: + + ```typescript + import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core'; + + server.registerTool( + 'greet', + { + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator()) + }, + handler + ); + ``` + + **Breaking changes:** + - `experimental.tasks.getTaskResult()` no longer accepts a `resultSchema` parameter. Returns `GetTaskPayloadResult` (a loose `Result`); cast to the expected type at the call site. + - Removed unused exports from `@modelcontextprotocol/core`: `SchemaInput`, `schemaToJson`, `parseSchemaAsync`, `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema`. Use the new `standardSchemaToJsonSchema` and `validateStandardSchema` instead. + - `completable()` remains Zod-specific (it relies on Zod's `.shape` introspection). + +- [#1710](https://github.com/modelcontextprotocol/typescript-sdk/pull/1710) [`e563e63`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e563e63bd2b3c2c1d1137406bef3f842c946201e) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add `AuthProvider` for + composable bearer-token auth; transports adapt `OAuthClientProvider` automatically + - New `AuthProvider` interface: `{ token(): Promise; onUnauthorized?(ctx): Promise }`. Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry once). + - Transport `authProvider` option now accepts `AuthProvider | OAuthClientProvider`. OAuth providers are adapted internally via `adaptOAuthProvider()` — no changes needed to existing `OAuthClientProvider` implementations. + - For simple bearer tokens (API keys, gateway-managed tokens, service accounts): `{ authProvider: { token: async () => myKey } }` — one-line object literal, no class. + - New `adaptOAuthProvider(provider)` export for explicit adaptation. + - New `handleOAuthUnauthorized(provider, ctx)` helper — the standard OAuth `onUnauthorized` behavior. + - New `isOAuthClientProvider()` type guard. + - New `UnauthorizedContext` type. + - Exported previously-internal auth helpers for building custom flows: `applyBasicAuth`, `applyPostAuth`, `applyPublicAuth`, `executeTokenRequest`. + + Transports are simplified internally — ~50 lines of inline OAuth orchestration (auth() calls, WWW-Authenticate parsing, circuit-breaker state) moved into the adapter's `onUnauthorized()` implementation. `OAuthClientProvider` itself is unchanged. + +- [#1614](https://github.com/modelcontextprotocol/typescript-sdk/pull/1614) [`1a78b01`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1a78b0138f1f3432968e53e810bac7929833eda2) Thanks [@pcarleton](https://github.com/pcarleton)! - Apply resolved scope consistently + to both DCR and the authorization URL (SEP-835) + + When `scopes_supported` is present in the protected resource metadata (`/.well-known/oauth-protected-resource`), the SDK already uses it as the default scope for the authorization URL. This change applies the same resolved scope to the dynamic client registration request + body, ensuring both use a consistent value. + - `registerClient()` now accepts an optional `scope` parameter that overrides `clientMetadata.scope` in the registration body. + - `auth()` now computes the resolved scope once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes it to both DCR and the authorization request. + +### Patch Changes + +- [#1758](https://github.com/modelcontextprotocol/typescript-sdk/pull/1758) [`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tasks - disallow requesting + a null TTL + +- [#1824](https://github.com/modelcontextprotocol/typescript-sdk/pull/1824) [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Drop `zod` from + `peerDependencies` (kept as direct dependency) + + Since Standard Schema support landed, `zod` is purely an internal runtime dependency used for protocol message parsing. User-facing schemas (`registerTool`, `registerPrompt`) accept any Standard Schema library. `zod` remains in `dependencies` and auto-installs; users no + longer need to install it alongside the SDK. + +- [#1761](https://github.com/modelcontextprotocol/typescript-sdk/pull/1761) [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Convert remaining + capability-assertion throws to `SdkError(SdkErrorCode.CapabilityNotSupported, ...)`. Follow-up to #1454 which missed `Client.assertCapability()`, the task capability helpers in `experimental/tasks/helpers.ts`, and the sampling/elicitation capability checks in + `experimental/tasks/server.ts`. + +- [#1632](https://github.com/modelcontextprotocol/typescript-sdk/pull/1632) [`d99f3ee`](https://github.com/modelcontextprotocol/typescript-sdk/commit/d99f3ee5274bb17bb0eb02c85381200feb4b43e6) Thanks [@matantsach](https://github.com/matantsach)! - Continue OAuth metadata discovery + on 502 (Bad Gateway) responses, matching the existing behavior for 4xx. This fixes MCP servers behind reverse proxies that return 502 for path-aware metadata URLs. Other 5xx errors still throw to avoid retrying against overloaded servers. + +- [#1772](https://github.com/modelcontextprotocol/typescript-sdk/pull/1772) [`5276439`](https://github.com/modelcontextprotocol/typescript-sdk/commit/527643966e42a91711c50a0a6609f941f1dfe3e2) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Always set + `windowsHide` when spawning stdio server processes on Windows, not just in Electron environments. Prevents unwanted console windows in non-Electron Windows applications. + +- [#1390](https://github.com/modelcontextprotocol/typescript-sdk/pull/1390) [`9bc9abc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9bc9abc68bf2b097b15c76a9673d44fb3ff31d03) Thanks [@DePasqualeOrg](https://github.com/DePasqualeOrg)! - Fix + StreamableHTTPClientTransport to handle error responses in SSE streams + +- [#1343](https://github.com/modelcontextprotocol/typescript-sdk/pull/1343) [`4b5fdcb`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4b5fdcba02c20f26d8b0f07acc87248288522842) Thanks [@christso](https://github.com/christso)! - Fix OAuth error handling for servers + returning errors with HTTP 200 status + + Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status instead of 4xx. The SDK now checks for an `error` field in the JSON response before attempting to parse it as tokens, providing users with meaningful error messages. + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1386](https://github.com/modelcontextprotocol/typescript-sdk/pull/1386) [`00249ce`](https://github.com/modelcontextprotocol/typescript-sdk/commit/00249ce86dac558fb1089aea46d4d6d14e9a56c6) Thanks [@PederHP](https://github.com/PederHP)! - Respect capability negotiation in list + methods by returning empty lists when server lacks capability + + The Client now returns empty lists instead of sending requests to servers that don't advertise the corresponding capability: + - `listPrompts()` returns `{ prompts: [] }` if server lacks prompts capability + - `listResources()` returns `{ resources: [] }` if server lacks resources capability + - `listResourceTemplates()` returns `{ resourceTemplates: [] }` if server lacks resources capability + - `listTools()` returns `{ tools: [] }` if server lacks tools capability + + This respects the MCP spec requirement that "Both parties SHOULD respect capability negotiation" and avoids unnecessary server warnings and traffic. The existing `enforceStrictCapabilities` option continues to throw errors when set to `true`. + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- [#1595](https://github.com/modelcontextprotocol/typescript-sdk/pull/1595) [`13a0d34`](https://github.com/modelcontextprotocol/typescript-sdk/commit/13a0d345c0b88bf73264c41a793bf0ad44cfa620) Thanks [@bhosmer-ant](https://github.com/bhosmer-ant)! - Don't swallow fetch `TypeError` + as CORS in non-browser environments. Network errors (DNS resolution failure, connection refused, invalid URL) in Node.js and Cloudflare Workers now propagate from OAuth discovery instead of being silently misattributed to CORS and returning `undefined`. This surfaces the real + error to callers rather than masking it as "metadata not found." + +- [#1279](https://github.com/modelcontextprotocol/typescript-sdk/pull/1279) [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Initial 2.0.0-alpha.0 + client and server package diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..589f566 --- /dev/null +++ b/packages/client/README.md @@ -0,0 +1,24 @@ +# `@modelcontextprotocol/client` + +The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that connect to MCP servers. + + +> [!WARNING] +> **This is an alpha release.** Expect breaking changes until v2 stabilizes. We're publishing early to gather feedback — please try it and open issues — but we can't guarantee API stability yet. We'll aim to minimize disruption between alphas. + + +> [!NOTE] +> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration.md)** if you're coming from v1. + +## Install + +```bash +npm install @modelcontextprotocol/client@alpha +``` + +## Documentation + +- **[Repository README](https://github.com/modelcontextprotocol/typescript-sdk#readme)** — overview, package layout, examples +- **[Client guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/client.md)** +- **[API reference](https://ts.sdk.modelcontextprotocol.io/v2/)** +- **[MCP specification](https://modelcontextprotocol.io)** diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs new file mode 100644 index 0000000..4f034f2 --- /dev/null +++ b/packages/client/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/core' + } + } +]; diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..537804b --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,111 @@ +{ + "name": "@modelcontextprotocol/client", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Client package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "client" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./stdio": { + "types": "./dist/stdio.d.mts", + "import": "./dist/stdio.mjs" + }, + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" + }, + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsBrowser.d.mts", + "import": "./dist/shimsBrowser.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "validators/cf-worker": [ + "dist/validators/cfWorker.d.mts" + ], + "stdio": [ + "dist/stdio.d.mts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "cross-spawn": "catalog:runtimeClientOnly", + "eventsource": "catalog:runtimeClientOnly", + "eventsource-parser": "catalog:runtimeClientOnly", + "jose": "catalog:runtimeClientOnly", + "pkce-challenge": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@types/content-type": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "@eslint/js": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools", + "tsdown": "catalog:devTools" + } +} diff --git a/packages/client/src/client/auth.examples.ts b/packages/client/src/client/auth.examples.ts new file mode 100644 index 0000000..17c04e6 --- /dev/null +++ b/packages/client/src/client/auth.examples.ts @@ -0,0 +1,63 @@ +/** + * Type-checked examples for `auth.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { AuthorizationServerMetadata } from '@modelcontextprotocol/core'; + +import type { OAuthClientProvider } from './auth.js'; +import { fetchToken } from './auth.js'; + +/** + * Base class providing no-op implementations of required OAuthClientProvider methods. + * Used as a base for concise examples that focus on specific methods. + */ +abstract class MyProviderBase implements OAuthClientProvider { + get redirectUrl(): URL | undefined { + return; + } + get clientMetadata() { + return { redirect_uris: [] as string[] }; + } + clientInformation(): undefined { + return; + } + tokens(): undefined { + return; + } + saveTokens() { + return Promise.resolve(); + } + redirectToAuthorization() { + return Promise.resolve(); + } + saveCodeVerifier() { + return Promise.resolve(); + } + codeVerifier() { + return Promise.resolve(''); + } +} + +/** + * Example: Using fetchToken with a client_credentials provider. + */ +async function fetchToken_clientCredentials(authServerUrl: URL, metadata: AuthorizationServerMetadata) { + //#region fetchToken_clientCredentials + // Provider for client_credentials: + class MyProvider extends MyProviderBase implements OAuthClientProvider { + prepareTokenRequest(scope?: string) { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } + } + + const tokens = await fetchToken(new MyProvider(), authServerUrl, { metadata }); + //#endregion fetchToken_clientCredentials + return tokens; +} diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts new file mode 100644 index 0000000..5f55fb7 --- /dev/null +++ b/packages/client/src/client/auth.ts @@ -0,0 +1,1745 @@ +import { CORS_IS_POSSIBLE } from '@modelcontextprotocol/client/_shims'; +import type { + AuthorizationServerMetadata, + FetchLike, + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthMetadata, + OAuthProtectedResourceMetadata, + OAuthTokens +} from '@modelcontextprotocol/core'; +import { + checkResourceAllowed, + LATEST_PROTOCOL_VERSION, + OAuthClientInformationFullSchema, + OAuthError, + OAuthErrorCode, + OAuthErrorResponseSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokensSchema, + OpenIdProviderDiscoveryMetadataSchema, + resourceUrlFromServerUrl +} from '@modelcontextprotocol/core'; +import pkceChallenge from 'pkce-challenge'; + +/** + * Function type for adding client authentication to token requests. + */ +export type AddClientAuthentication = ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata +) => void | Promise; + +/** + * Context passed to {@linkcode AuthProvider.onUnauthorized} when the server + * responds with 401. Provides everything needed to refresh credentials. + */ +export interface UnauthorizedContext { + /** The 401 response — inspect `WWW-Authenticate` for resource metadata, scope, etc. */ + response: Response; + /** The MCP server URL, for passing to {@linkcode auth} or discovery helpers. */ + serverUrl: URL; + /** Fetch function configured with the transport's `requestInit`, for making auth requests. */ + fetchFn: FetchLike; +} + +/** + * Minimal interface for authenticating MCP client transports with bearer tokens. + * + * Transports call {@linkcode AuthProvider.token | token()} before every request + * to obtain the current token, and {@linkcode AuthProvider.onUnauthorized | onUnauthorized()} + * (if provided) when the server responds with 401, giving the provider a chance + * to refresh credentials before the transport retries once. + * + * For simple cases (API keys, gateway-managed tokens), implement only `token()`: + * ```typescript + * const authProvider: AuthProvider = { token: async () => process.env.API_KEY }; + * ``` + * + * For OAuth flows, pass an {@linkcode OAuthClientProvider} directly — transports + * accept either shape and adapt OAuth providers automatically via {@linkcode adaptOAuthProvider}. + */ +export interface AuthProvider { + /** + * Returns the current bearer token, or `undefined` if no token is available. + * Called before every request. + */ + token(): Promise; + + /** + * Called when the server responds with 401. If provided, the transport will + * await this, then retry the request once. If the retry also gets 401, or if + * this method is not provided, the transport throws {@linkcode UnauthorizedError}. + * + * Implementations should refresh tokens, re-authenticate, etc. — whatever is + * needed so the next `token()` call returns a valid token. + */ + onUnauthorized?(ctx: UnauthorizedContext): Promise; +} + +/** + * Type guard distinguishing `OAuthClientProvider` from a minimal `AuthProvider`. + * Transports use this at construction time to classify the `authProvider` option. + * + * Checks for `tokens()` + `clientInformation()` — two required `OAuthClientProvider` + * methods that a minimal `AuthProvider` `{ token: ... }` would never have. + */ +export function isOAuthClientProvider(provider: AuthProvider | OAuthClientProvider | undefined): provider is OAuthClientProvider { + if (provider == null) return false; + const p = provider as OAuthClientProvider; + return typeof p.tokens === 'function' && typeof p.clientInformation === 'function'; +} + +/** + * Standard `onUnauthorized` behavior for OAuth providers: extracts + * `WWW-Authenticate` parameters from the 401 response and runs {@linkcode auth}. + * Used by {@linkcode adaptOAuthProvider} to bridge `OAuthClientProvider` to `AuthProvider`. + */ +export async function handleOAuthUnauthorized(provider: OAuthClientProvider, ctx: UnauthorizedContext): Promise { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response); + const result = await auth(provider, { + serverUrl: ctx.serverUrl, + resourceMetadataUrl, + scope, + fetchFn: ctx.fetchFn + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } +} + +/** + * Adapts an `OAuthClientProvider` to the minimal `AuthProvider` interface that + * transports consume. Called once at transport construction — the transport stores + * the adapted provider for `_commonHeaders()` and 401 handling, while keeping the + * original `OAuthClientProvider` for OAuth-specific paths (`finishAuth()`, 403 upscoping). + */ +export function adaptOAuthProvider(provider: OAuthClientProvider): AuthProvider { + return { + token: async () => { + const tokens = await provider.tokens(); + return tokens?.access_token; + }, + onUnauthorized: async ctx => handleOAuthUnauthorized(provider, ctx) + }; +} + +/** + * Implements an end-to-end OAuth client to be used with one MCP server. + * + * This client relies upon a concept of an authorized "session," the exact + * meaning of which is application-defined. Tokens, authorization codes, and + * code verifiers should not cross different sessions. + * + * Transports accept `OAuthClientProvider` directly via the `authProvider` option — + * they adapt it to {@linkcode AuthProvider} internally via {@linkcode adaptOAuthProvider}. + * No changes are needed to existing implementations. + */ +export interface OAuthClientProvider { + /** + * The URL to redirect the user agent to after authorization. + * Return `undefined` for non-interactive flows that don't require user interaction + * (e.g., `client_credentials`, `jwt-bearer`). + */ + get redirectUrl(): string | URL | undefined; + + /** + * External URL the server should use to fetch client metadata document + */ + clientMetadataUrl?: string; + + /** + * Metadata about this OAuth client. + */ + get clientMetadata(): OAuthClientMetadata; + + /** + * Returns an OAuth2 state parameter. + */ + state?(): string | Promise; + + /** + * Loads information about this OAuth client, as registered already with the + * server, or returns `undefined` if the client is not registered with the + * server. + */ + clientInformation(): OAuthClientInformationMixed | undefined | Promise; + + /** + * If implemented, this permits the OAuth client to dynamically register with + * the server. Client information saved this way should later be read via + * {@linkcode OAuthClientProvider.clientInformation | clientInformation()}. + * + * This method is not required to be implemented if client information is + * statically known (e.g., pre-registered). + */ + saveClientInformation?(clientInformation: OAuthClientInformationMixed): void | Promise; + + /** + * Loads any existing OAuth tokens for the current session, or returns + * `undefined` if there are no saved tokens. + */ + tokens(): OAuthTokens | undefined | Promise; + + /** + * Stores new OAuth tokens for the current session, after a successful + * authorization. + */ + saveTokens(tokens: OAuthTokens): void | Promise; + + /** + * Invoked to redirect the user agent to the given URL to begin the authorization flow. + */ + redirectToAuthorization(authorizationUrl: URL): void | Promise; + + /** + * Saves a PKCE code verifier for the current session, before redirecting to + * the authorization flow. + */ + saveCodeVerifier(codeVerifier: string): void | Promise; + + /** + * Loads the PKCE code verifier for the current session, necessary to validate + * the authorization result. + */ + codeVerifier(): string | Promise; + + /** + * Adds custom client authentication to OAuth token requests. + * + * This optional method allows implementations to customize how client credentials + * are included in token exchange and refresh requests. When provided, this method + * is called instead of the default authentication logic, giving full control over + * the authentication mechanism. + * + * Common use cases include: + * - Supporting authentication methods beyond the standard OAuth 2.0 methods + * - Adding custom headers for proprietary authentication schemes + * - Implementing client assertion-based authentication (e.g., JWT bearer tokens) + * + * @param headers - The request headers (can be modified to add authentication) + * @param params - The request body parameters (can be modified to add credentials) + * @param url - The token endpoint URL being called + * @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods + */ + addClientAuthentication?: AddClientAuthentication; + + /** + * If defined, overrides the selection and validation of the + * RFC 8707 Resource Indicator. If left undefined, default + * validation behavior will be used. + * + * Implementations must verify the returned resource matches the MCP server. + */ + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; + + /** + * If implemented, provides a way for the client to invalidate (e.g. delete) the specified + * credentials, in the case where the server has indicated that they are no longer valid. + * This avoids requiring the user to intervene manually. + */ + invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise; + + /** + * Prepares grant-specific parameters for a token request. + * + * This optional method allows providers to customize the token request based on + * the grant type they support. When implemented, it returns the grant type and + * any grant-specific parameters needed for the token exchange. + * + * If not implemented, the default behavior depends on the flow: + * - For authorization code flow: uses `code`, `code_verifier`, and `redirect_uri` + * - For `client_credentials`: detected via `grant_types` in {@linkcode OAuthClientProvider.clientMetadata | clientMetadata} + * + * @param scope - Optional scope to request + * @returns Grant type and parameters, or `undefined` to use default behavior + * + * @example + * // For client_credentials grant: + * prepareTokenRequest(scope) { + * return { + * grantType: 'client_credentials', + * params: scope ? { scope } : {} + * }; + * } + * + * @example + * // For authorization_code grant (default behavior): + * async prepareTokenRequest() { + * return { + * grantType: 'authorization_code', + * params: { + * code: this.authorizationCode, + * code_verifier: await this.codeVerifier(), + * redirect_uri: String(this.redirectUrl) + * } + * }; + * } + */ + prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; + + /** + * Saves the authorization server URL after RFC 9728 discovery. + * This method is called by {@linkcode auth} after successful discovery of the + * authorization server via protected resource metadata. + * + * Providers implementing Cross-App Access or other flows that need access to + * the discovered authorization server URL should implement this method. + * + * @param authorizationServerUrl - The authorization server URL discovered via RFC 9728 + */ + saveAuthorizationServerUrl?(authorizationServerUrl: string): void | Promise; + + /** + * Returns the previously saved authorization server URL, if available. + * + * Providers implementing Cross-App Access can use this to access the + * authorization server URL discovered during the OAuth flow. + * + * @returns The authorization server URL, or `undefined` if not available + */ + authorizationServerUrl?(): string | undefined | Promise; + + /** + * Saves the resource URL after RFC 9728 discovery. + * This method is called by {@linkcode auth} after successful discovery of the + * resource metadata. + * + * Providers implementing Cross-App Access or other flows that need access to + * the discovered resource URL should implement this method. + * + * @param resourceUrl - The resource URL discovered via RFC 9728 + */ + saveResourceUrl?(resourceUrl: string): void | Promise; + + /** + * Returns the previously saved resource URL, if available. + * + * Providers implementing Cross-App Access can use this to access the + * resource URL discovered during the OAuth flow. + * + * @returns The resource URL, or `undefined` if not available + */ + resourceUrl?(): string | undefined | Promise; + + /** + * Saves the OAuth discovery state after RFC 9728 and authorization server metadata + * discovery. Providers can persist this state to avoid redundant discovery requests + * on subsequent {@linkcode auth} calls. + * + * This state can also be provided out-of-band (e.g., from a previous session or + * external configuration) to bootstrap the OAuth flow without discovery. + * + * Called by {@linkcode auth} after successful discovery. + */ + saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; + + /** + * Returns previously saved discovery state, or `undefined` if none is cached. + * + * When available, {@linkcode auth} restores the discovery state (authorization server + * URL, resource metadata, etc.) instead of performing RFC 9728 discovery, reducing + * latency on subsequent calls. + * + * Providers should clear cached discovery state on repeated authentication failures + * (via {@linkcode invalidateCredentials} with scope `'discovery'` or `'all'`) to allow + * re-discovery in case the authorization server has changed. + */ + discoveryState?(): OAuthDiscoveryState | undefined | Promise; +} + +/** + * Discovery state that can be persisted across sessions by an {@linkcode OAuthClientProvider}. + * + * Contains the results of RFC 9728 protected resource metadata discovery and + * authorization server metadata discovery. Persisting this state avoids + * redundant discovery HTTP requests on subsequent {@linkcode auth} calls. + */ +// TODO: Consider adding `authorizationServerMetadataUrl` to capture the exact well-known URL +// at which authorization server metadata was discovered. This would require +// `discoverAuthorizationServerMetadata()` to return the successful discovery URL. +export interface OAuthDiscoveryState extends OAuthServerInfo { + /** The URL at which the protected resource metadata was found, if available. */ + resourceMetadataUrl?: string; +} + +export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; + +export class UnauthorizedError extends Error { + constructor(message?: string) { + super(message ?? 'Unauthorized'); + } +} + +export type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; + +function isClientAuthMethod(method: string): method is ClientAuthMethod { + return ['client_secret_basic', 'client_secret_post', 'none'].includes(method); +} + +const AUTHORIZATION_CODE_RESPONSE_TYPE = 'code'; +const AUTHORIZATION_CODE_CHALLENGE_METHOD = 'S256'; + +/** + * Determines the best client authentication method to use based on server support and client configuration. + * + * Priority order (highest to lowest): + * 1. `client_secret_basic` (if client secret is available) + * 2. `client_secret_post` (if client secret is available) + * 3. `none` (for public clients) + * + * @param clientInformation - OAuth client information containing credentials + * @param supportedMethods - Authentication methods supported by the authorization server + * @returns The selected authentication method + */ +export function selectClientAuthMethod(clientInformation: OAuthClientInformationMixed, supportedMethods: string[]): ClientAuthMethod { + const hasClientSecret = clientInformation.client_secret !== undefined; + + // Prefer the method returned by the server during client registration, if valid. + // When server metadata is present we also require the method to be listed as supported; + // when supportedMethods is empty (metadata omitted the field) the DCR hint stands alone. + if ( + 'token_endpoint_auth_method' in clientInformation && + clientInformation.token_endpoint_auth_method && + isClientAuthMethod(clientInformation.token_endpoint_auth_method) && + (supportedMethods.length === 0 || supportedMethods.includes(clientInformation.token_endpoint_auth_method)) + ) { + return clientInformation.token_endpoint_auth_method; + } + + // If server metadata omits token_endpoint_auth_methods_supported, RFC 8414 §2 says the + // default is client_secret_basic. RFC 6749 §2.3.1 also requires servers to support HTTP + // Basic authentication for clients with a secret, making it the safest default. + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_basic' : 'none'; + } + + // Try methods in priority order (most secure first) + if (hasClientSecret && supportedMethods.includes('client_secret_basic')) { + return 'client_secret_basic'; + } + + if (hasClientSecret && supportedMethods.includes('client_secret_post')) { + return 'client_secret_post'; + } + + if (supportedMethods.includes('none')) { + return 'none'; + } + + // Fallback: use what we have + return hasClientSecret ? 'client_secret_post' : 'none'; +} + +/** + * Applies client authentication to the request based on the specified method. + * + * Implements OAuth 2.1 client authentication methods: + * - `client_secret_basic`: HTTP Basic authentication (RFC 6749 Section 2.3.1) + * - `client_secret_post`: Credentials in request body (RFC 6749 Section 2.3.1) + * - `none`: Public client authentication (RFC 6749 Section 2.1) + * + * @param method - The authentication method to use + * @param clientInformation - OAuth client information containing credentials + * @param headers - HTTP headers object to modify + * @param params - URL search parameters to modify + * @throws {Error} When required credentials are missing + */ +export function applyClientAuthentication( + method: ClientAuthMethod, + clientInformation: OAuthClientInformation, + headers: Headers, + params: URLSearchParams +): void { + const { client_id, client_secret } = clientInformation; + + switch (method) { + case 'client_secret_basic': { + applyBasicAuth(client_id, client_secret, headers); + return; + } + case 'client_secret_post': { + applyPostAuth(client_id, client_secret, params); + return; + } + case 'none': { + applyPublicAuth(client_id, params); + return; + } + default: { + throw new Error(`Unsupported client authentication method: ${method}`); + } + } +} + +/** + * Applies HTTP Basic authentication (RFC 6749 Section 2.3.1) + */ +export function applyBasicAuth(clientId: string, clientSecret: string | undefined, headers: Headers): void { + if (!clientSecret) { + throw new Error('client_secret_basic authentication requires a client_secret'); + } + + const credentials = btoa(`${clientId}:${clientSecret}`); + headers.set('Authorization', `Basic ${credentials}`); +} + +/** + * Applies POST body authentication (RFC 6749 Section 2.3.1) + */ +export function applyPostAuth(clientId: string, clientSecret: string | undefined, params: URLSearchParams): void { + params.set('client_id', clientId); + if (clientSecret) { + params.set('client_secret', clientSecret); + } +} + +/** + * Applies public client authentication (RFC 6749 Section 2.1) + */ +export function applyPublicAuth(clientId: string, params: URLSearchParams): void { + params.set('client_id', clientId); +} + +/** + * Parses an OAuth error response from a string or Response object. + * + * If the input is a standard OAuth2.0 error response, it will be parsed according to the spec + * and an {@linkcode OAuthError} will be returned with the appropriate error code. + * If parsing fails, it falls back to a generic {@linkcode OAuthErrorCode.ServerError | ServerError} that includes + * the response status (if available) and original content. + * + * @param input - A Response object or string containing the error response + * @returns A Promise that resolves to an {@linkcode OAuthError} instance + */ +export async function parseErrorResponse(input: Response | string): Promise { + const statusCode = input instanceof Response ? input.status : undefined; + const body = input instanceof Response ? await input.text() : input; + + try { + const result = OAuthErrorResponseSchema.parse(JSON.parse(body)); + return OAuthError.fromResponse(result); + } catch (error) { + // Not a valid OAuth error response, but try to inform the user of the raw data anyway + const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`; + return new OAuthError(OAuthErrorCode.ServerError, errorMessage); + } +} + +/** + * Orchestrates the full auth flow with a server. + * + * This can be used as a single entry point for all authorization functionality, + * instead of linking together the other lower-level functions in this module. + */ +export async function auth( + provider: OAuthClientProvider, + options: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + try { + return await authInternal(provider, options); + } catch (error) { + // Handle recoverable error types by invalidating credentials and retrying + if (error instanceof OAuthError) { + if (error.code === OAuthErrorCode.InvalidClient || error.code === OAuthErrorCode.UnauthorizedClient) { + await provider.invalidateCredentials?.('all'); + return await authInternal(provider, options); + } else if (error.code === OAuthErrorCode.InvalidGrant) { + await provider.invalidateCredentials?.('tokens'); + return await authInternal(provider, options); + } + } + + // Throw otherwise + throw error; + } +} + +/** + * Selects scopes per the MCP spec and augment for refresh token support. + */ +export function determineScope(options: { + requestedScope?: string; + resourceMetadata?: OAuthProtectedResourceMetadata; + authServerMetadata?: AuthorizationServerMetadata; + clientMetadata: OAuthClientMetadata; +}): string | undefined { + const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options; + + // Scope selection priority (MCP spec): + // 1. WWW-Authenticate header scope + // 2. PRM scopes_supported + // 3. clientMetadata.scope (SDK fallback) + // 4. Omit scope parameter + let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; + + // SEP-2207: Append offline_access when the AS advertises it + // and the client supports the refresh_token grant. + if ( + effectiveScope && + authServerMetadata?.scopes_supported?.includes('offline_access') && + !effectiveScope.split(' ').includes('offline_access') && + clientMetadata.grant_types?.includes('refresh_token') + ) { + effectiveScope = `${effectiveScope} offline_access`; + } + + return effectiveScope; +} + +async function authInternal( + provider: OAuthClientProvider, + { + serverUrl, + authorizationCode, + scope, + resourceMetadataUrl, + fetchFn + }: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); + + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | URL; + let metadata: AuthorizationServerMetadata | undefined; + + // If resourceMetadataUrl is not provided, try to load it from cached state + // This handles browser redirects where the URL was saved before navigation + let effectiveResourceMetadataUrl = resourceMetadataUrl; + if (!effectiveResourceMetadataUrl && cachedState?.resourceMetadataUrl) { + effectiveResourceMetadataUrl = new URL(cachedState.resourceMetadataUrl); + } + + if (cachedState?.authorizationServerUrl) { + // Restore discovery state from cache + authorizationServerUrl = cachedState.authorizationServerUrl; + resourceMetadata = cachedState.resourceMetadata; + metadata = + cachedState.authorizationServerMetadata ?? (await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn })); + + // If resource metadata wasn't cached, try to fetch it for selectResourceURL + if (!resourceMetadata) { + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: effectiveResourceMetadataUrl }, + fetchFn + ); + } catch (error) { + // Network failures (DNS, connection refused) surface as TypeError — propagate + // those rather than masking a transient reachability problem. + if (error instanceof TypeError) { + throw error; + } + // RFC 9728 not available — selectResourceURL will handle undefined + } + } + + // Re-save if we enriched the cached state with missing metadata + if (metadata !== cachedState.authorizationServerMetadata || resourceMetadata !== cachedState.resourceMetadata) { + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + } else { + // Full discovery via RFC 9728 + const serverInfo = await discoverOAuthServerInfo(serverUrl, { resourceMetadataUrl: effectiveResourceMetadataUrl, fetchFn }); + authorizationServerUrl = serverInfo.authorizationServerUrl; + metadata = serverInfo.authorizationServerMetadata; + resourceMetadata = serverInfo.resourceMetadata; + + // Persist discovery state for future use + // TODO: resourceMetadataUrl is only populated when explicitly provided via options + // or loaded from cached state. The URL derived internally by + // discoverOAuthProtectedResourceMetadata() is not captured back here. + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: effectiveResourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata + }); + } + + // Save authorization server URL for providers that need it (e.g., CrossAppAccessProvider) + await provider.saveAuthorizationServerUrl?.(String(authorizationServerUrl)); + + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + + // Save resource URL for providers that need it (e.g., CrossAppAccessProvider) + if (resource) { + await provider.saveResourceUrl?.(String(resource)); + } + + // Scope selection used consistently for DCR and the authorization request. + const resolvedScope = determineScope({ + requestedScope: scope, + resourceMetadata, + authServerMetadata: metadata, + clientMetadata: provider.clientMetadata + }); + + // Handle client registration if needed + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { + if (authorizationCode !== undefined) { + throw new Error('Existing OAuth client information is required when exchanging an authorization code'); + } + + const supportsUrlBasedClientId = metadata?.client_id_metadata_document_supported === true; + const clientMetadataUrl = provider.clientMetadataUrl; + + if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { + throw new OAuthError( + OAuthErrorCode.InvalidClientMetadata, + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}` + ); + } + + const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl; + + if (shouldUseUrlBasedClientId) { + // SEP-991: URL-based Client IDs + clientInformation = { + client_id: clientMetadataUrl + }; + await provider.saveClientInformation?.(clientInformation); + } else { + // Fallback to dynamic registration + if (!provider.saveClientInformation) { + throw new Error('OAuth client information must be saveable for dynamic registration'); + } + + const fullInformation = await registerClient(authorizationServerUrl, { + metadata, + clientMetadata: provider.clientMetadata, + scope: resolvedScope, + fetchFn + }); + + await provider.saveClientInformation(fullInformation); + clientInformation = fullInformation; + } + } + + // Non-interactive flows (e.g., client_credentials, jwt-bearer) don't need a redirect URL + const nonInteractiveFlow = !provider.redirectUrl; + + // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows + if (authorizationCode !== undefined || nonInteractiveFlow) { + const tokens = await fetchToken(provider, authorizationServerUrl, { + metadata, + resource, + authorizationCode, + scope: resolvedScope, + fetchFn + }); + + await provider.saveTokens(tokens); + return 'AUTHORIZED'; + } + + const tokens = await provider.tokens(); + + // Handle token refresh or new authorization + if (tokens?.refresh_token) { + try { + // Attempt to refresh the token + const newTokens = await refreshAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + refreshToken: tokens.refresh_token, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn + }); + + await provider.saveTokens(newTokens); + return 'AUTHORIZED'; + } catch (error) { + // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. + if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) { + // Could not refresh OAuth tokens + } else { + // Refresh failed for another reason, re-throw + throw error; + } + } + } + + const state = provider.state ? await provider.state() : undefined; + + // Start new authorization flow + const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + state, + redirectUrl: provider.redirectUrl, + scope: resolvedScope, + resource + }); + + await provider.saveCodeVerifier(codeVerifier); + await provider.redirectToAuthorization(authorizationUrl); + return 'REDIRECT'; +} + +/** + * Validates that the given `clientMetadataUrl` is a valid HTTPS URL with a non-root pathname. + * + * No-op when `url` is `undefined` or empty (providers that do not use URL-based client IDs + * are unaffected). When the value is defined but invalid, throws an {@linkcode OAuthError} + * with code {@linkcode OAuthErrorCode.InvalidClientMetadata}. + * + * {@linkcode OAuthClientProvider} implementations that accept a `clientMetadataUrl` should + * call this in their constructors for early validation. + * + * @param url - The `clientMetadataUrl` value to validate (from `OAuthClientProvider.clientMetadataUrl`) + * @throws {OAuthError} When `url` is defined but is not a valid HTTPS URL with a non-root pathname + */ +export function validateClientMetadataUrl(url: string | undefined): void { + if (url && !isHttpsUrl(url)) { + throw new OAuthError( + OAuthErrorCode.InvalidClientMetadata, + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${url}` + ); + } +} + +/** + * SEP-991: URL-based Client IDs + * Validate that the `client_id` is a valid URL with `https` scheme + */ +export function isHttpsUrl(value?: string): boolean { + if (!value) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.pathname !== '/'; + } catch { + return false; + } +} + +export async function selectResourceURL( + serverUrl: string | URL, + provider: OAuthClientProvider, + resourceMetadata?: OAuthProtectedResourceMetadata +): Promise { + const defaultResource = resourceUrlFromServerUrl(serverUrl); + + // If provider has custom validation, delegate to it + if (provider.validateResourceURL) { + return await provider.validateResourceURL(defaultResource, resourceMetadata?.resource); + } + + // Only include resource parameter when Protected Resource Metadata is present + if (!resourceMetadata) { + return undefined; + } + + // Validate that the metadata's resource is compatible with our request + if (!checkResourceAllowed({ requestedResource: defaultResource, configuredResource: resourceMetadata.resource })) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${defaultResource} (or origin)`); + } + // Prefer the resource from metadata since it's what the server is telling us to request + return new URL(resourceMetadata.resource); +} + +/** + * Extract `resource_metadata`, `scope`, and `error` from `WWW-Authenticate` header. + */ +export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { + const authenticateHeader = res.headers.get('WWW-Authenticate'); + if (!authenticateHeader) { + return {}; + } + + const [type, scheme] = authenticateHeader.split(' '); + if (type?.toLowerCase() !== 'bearer' || !scheme) { + return {}; + } + + const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined; + + let resourceMetadataUrl: URL | undefined; + if (resourceMetadataMatch) { + try { + resourceMetadataUrl = new URL(resourceMetadataMatch); + } catch { + // Ignore invalid URL + } + } + + const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; + const error = extractFieldFromWwwAuth(res, 'error') || undefined; + + return { + resourceMetadataUrl, + scope, + error + }; +} + +/** + * Extracts a specific field's value from the `WWW-Authenticate` header string. + * + * @param response The HTTP response object containing the headers. + * @param fieldName The name of the field to extract (e.g., `"realm"`, `"nonce"`). + * @returns The field value + */ +function extractFieldFromWwwAuth(response: Response, fieldName: string): string | null { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (!wwwAuthHeader) { + return null; + } + + const pattern = new RegExp(String.raw`${fieldName}=(?:"([^"]+)"|([^\s,]+))`); + const match = wwwAuthHeader.match(pattern); + + if (match) { + // Pattern matches: field_name="value" or field_name=value (unquoted) + const result = match[1] || match[2]; + if (result) { + return result; + } + } + + return null; +} + +/** + * Extract `resource_metadata` from response header. + * @deprecated Use {@linkcode extractWWWAuthenticateParams} instead. + */ +export function extractResourceMetadataUrl(res: Response): URL | undefined { + const authenticateHeader = res.headers.get('WWW-Authenticate'); + if (!authenticateHeader) { + return undefined; + } + + const [type, scheme] = authenticateHeader.split(' '); + if (type?.toLowerCase() !== 'bearer' || !scheme) { + return undefined; + } + const regex = /resource_metadata="([^"]*)"/; + const match = regex.exec(authenticateHeader); + + if (!match || !match[1]) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} + +/** + * Looks up {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + * OAuth 2.0 Protected Resource Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + */ +export async function discoverOAuthProtectedResourceMetadata( + serverUrl: string | URL, + opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL }, + fetchFn: FetchLike = fetch +): Promise { + const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, { + protocolVersion: opts?.protocolVersion, + metadataUrl: opts?.resourceMetadataUrl + }); + + if (!response || response.status === 404) { + await response?.text?.().catch(() => {}); + throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + } + + if (!response.ok) { + await response.text?.().catch(() => {}); + throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + +/** + * Fetch with a retry heuristic for CORS errors caused by custom headers. + * + * In browsers, adding a custom header (e.g. `MCP-Protocol-Version`) triggers a CORS preflight. + * If the server doesn't allow that header, the browser throws a `TypeError` before any response + * is received. Retrying without custom headers often succeeds because the request becomes + * "simple" (no preflight). If the server sends no CORS headers at all, the retry also fails + * with `TypeError` and we return `undefined` so callers can fall through to an alternate URL. + * + * However, `fetch()` also throws `TypeError` for non-CORS failures (DNS resolution, connection + * refused, invalid URL). Swallowing those and returning `undefined` masks real errors and can + * cause callers to silently fall through to a different discovery URL. CORS is a browser-only + * concept, so in non-browser runtimes (Node.js, Workers) a `TypeError` from `fetch` is never a + * CORS error — there we propagate the error instead of swallowing it. + * + * In browsers, we cannot reliably distinguish CORS `TypeError` from network `TypeError` from the + * error object alone, so the swallow-and-fallthrough heuristic is preserved there. + */ +async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { + try { + return await fetchFn(url, { headers }); + } catch (error) { + if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) { + throw error; + } + if (headers) { + // Could be a CORS preflight rejection caused by our custom header. Retry as a simple + // request: if that succeeds, we've sidestepped the preflight. + try { + return await fetchFn(url, {}); + } catch (retryError) { + if (!(retryError instanceof TypeError)) { + throw retryError; + } + // Retry also got CORS-blocked (server sends no CORS headers at all). + // Return undefined so the caller tries the next discovery URL. + return undefined; + } + } + return undefined; + } +} + +/** + * Constructs the well-known path for auth-related metadata discovery + */ +function buildWellKnownPath( + wellKnownPrefix: 'oauth-authorization-server' | 'oauth-protected-resource' | 'openid-configuration', + pathname: string = '', + options: { prependPathname?: boolean } = {} +): string { + // Strip trailing slash from pathname to avoid double slashes + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + return options.prependPathname ? `${pathname}/.well-known/${wellKnownPrefix}` : `/.well-known/${wellKnownPrefix}${pathname}`; +} + +/** + * Tries to discover OAuth metadata at a specific URL + */ +async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: FetchLike = fetch): Promise { + const headers = { + 'MCP-Protocol-Version': protocolVersion + }; + return await fetchWithCorsRetry(url, headers, fetchFn); +} + +/** + * Determines if fallback to root discovery should be attempted + */ +function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { + if (!response) return true; // CORS error — always try fallback + if (pathname === '/') return false; // Already at root + return (response.status >= 400 && response.status < 500) || response.status === 502; +} + +/** + * Generic function for discovering OAuth metadata with fallback support + */ +async function discoverMetadataWithFallback( + serverUrl: string | URL, + wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource', + fetchFn: FetchLike, + opts?: { protocolVersion?: string; metadataUrl?: string | URL; metadataServerUrl?: string | URL } +): Promise { + const issuer = new URL(serverUrl); + const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION; + + let url: URL; + if (opts?.metadataUrl) { + url = new URL(opts.metadataUrl); + } else { + // Try path-aware discovery first + const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname); + url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer); + url.search = issuer.search; + } + + let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); + + // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery + if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { + const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); + response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); + } + + return response; +} + +/** + * Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata. + * + * If the server returns a 404 for the well-known endpoint, this function will + * return `undefined`. Any other errors will be thrown as exceptions. + * + * @deprecated This function is deprecated in favor of {@linkcode discoverAuthorizationServerMetadata}. + */ +export async function discoverOAuthMetadata( + issuer: string | URL, + { + authorizationServerUrl, + protocolVersion + }: { + authorizationServerUrl?: string | URL; + protocolVersion?: string; + } = {}, + fetchFn: FetchLike = fetch +): Promise { + if (typeof issuer === 'string') { + issuer = new URL(issuer); + } + if (!authorizationServerUrl) { + authorizationServerUrl = issuer; + } + if (typeof authorizationServerUrl === 'string') { + authorizationServerUrl = new URL(authorizationServerUrl); + } + protocolVersion ??= LATEST_PROTOCOL_VERSION; + + const response = await discoverMetadataWithFallback(authorizationServerUrl, 'oauth-authorization-server', fetchFn, { + protocolVersion, + metadataServerUrl: authorizationServerUrl + }); + + if (!response || response.status === 404) { + await response?.text?.().catch(() => {}); + return undefined; + } + + if (!response.ok) { + await response.text?.().catch(() => {}); + throw new Error(`HTTP ${response.status} trying to load well-known OAuth metadata`); + } + + return OAuthMetadataSchema.parse(await response.json()); +} + +/** + * Builds a list of discovery URLs to try for authorization server metadata. + * URLs are returned in priority order: + * 1. OAuth metadata at the given URL + * 2. OIDC metadata endpoints at the given URL + */ +export function buildDiscoveryUrls(authorizationServerUrl: string | URL): { url: URL; type: 'oauth' | 'oidc' }[] { + const url = typeof authorizationServerUrl === 'string' ? new URL(authorizationServerUrl) : authorizationServerUrl; + const hasPath = url.pathname !== '/'; + const urlsToTry: { url: URL; type: 'oauth' | 'oidc' }[] = []; + + if (!hasPath) { + urlsToTry.push( + // Root path: https://example.com/.well-known/oauth-authorization-server + + { + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth' + }, + // OIDC: https://example.com/.well-known/openid-configuration + + { + url: new URL(`/.well-known/openid-configuration`, url.origin), + type: 'oidc' + } + ); + + return urlsToTry; + } + + // Strip trailing slash from pathname to avoid double slashes + let pathname = url.pathname; + if (pathname.endsWith('/')) { + pathname = pathname.slice(0, -1); + } + + urlsToTry.push( + // 1. OAuth metadata at the given URL + // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 + { + url: new URL(`/.well-known/oauth-authorization-server${pathname}`, url.origin), + type: 'oauth' + }, + // 2. OIDC metadata endpoints + // RFC 8414 style: Insert /.well-known/openid-configuration before the path + { + url: new URL(`/.well-known/openid-configuration${pathname}`, url.origin), + type: 'oidc' + }, + // OIDC Discovery 1.0 style: Append /.well-known/openid-configuration after the path + + { + url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), + type: 'oidc' + } + ); + + return urlsToTry; +} + +/** + * Discovers authorization server metadata with support for + * {@link https://datatracker.ietf.org/doc/html/rfc8414 | RFC 8414} OAuth 2.0 + * Authorization Server Metadata and + * {@link https://openid.net/specs/openid-connect-discovery-1_0.html | OpenID Connect Discovery 1.0} + * specifications. + * + * This function implements a fallback strategy for authorization server discovery: + * 1. Attempts RFC 8414 OAuth metadata discovery first + * 2. If OAuth discovery fails, falls back to OpenID Connect Discovery + * + * @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's + * protected resource metadata, or the MCP server's URL if the + * metadata was not found. + * @param options - Configuration options + * @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch + * @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION} + * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + */ +export async function discoverAuthorizationServerMetadata( + authorizationServerUrl: string | URL, + { + fetchFn = fetch, + protocolVersion = LATEST_PROTOCOL_VERSION + }: { + fetchFn?: FetchLike; + protocolVersion?: string; + } = {} +): Promise { + const headers = { + 'MCP-Protocol-Version': protocolVersion, + Accept: 'application/json' + }; + + // Get the list of URLs to try + const urlsToTry = buildDiscoveryUrls(authorizationServerUrl); + + // Try each URL in order + for (const { url: endpointUrl, type } of urlsToTry) { + const response = await fetchWithCorsRetry(endpointUrl, headers, fetchFn); + + if (!response) { + /** + * CORS error occurred - don't throw as the endpoint may not allow CORS, + * continue trying other possible endpoints + */ + continue; + } + + if (!response.ok) { + await response.text?.().catch(() => {}); + if ((response.status >= 400 && response.status < 500) || response.status === 502) { + continue; // Try next URL for 4xx or 502 (Bad Gateway) + } + throw new Error( + `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}` + ); + } + + // Parse and validate based on type + return type === 'oauth' + ? OAuthMetadataSchema.parse(await response.json()) + : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + } + + return undefined; +} + +/** + * Result of {@linkcode discoverOAuthServerInfo}. + */ +export interface OAuthServerInfo { + /** + * The authorization server URL, either discovered via RFC 9728 + * or derived from the MCP server URL as a fallback. + */ + authorizationServerUrl: string; + + /** + * The authorization server metadata (endpoints, capabilities), + * or `undefined` if metadata discovery failed. + */ + authorizationServerMetadata?: AuthorizationServerMetadata; + + /** + * The OAuth 2.0 Protected Resource Metadata from RFC 9728, + * or `undefined` if the server does not support it. + */ + resourceMetadata?: OAuthProtectedResourceMetadata; +} + +/** + * Discovers the authorization server for an MCP server following + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} (OAuth 2.0 Protected + * Resource Metadata), with fallback to treating the server URL as the + * authorization server. + * + * This function combines two discovery steps into one call: + * 1. Probes `/.well-known/oauth-protected-resource` on the MCP server to find the + * authorization server URL (RFC 9728). + * 2. Fetches authorization server metadata from that URL (RFC 8414 / OpenID Connect Discovery). + * + * Use this when you need the authorization server metadata for operations outside the + * {@linkcode auth} orchestrator, such as token refresh or token revocation. + * + * @param serverUrl - The MCP resource server URL + * @param opts - Optional configuration + * @param opts.resourceMetadataUrl - Override URL for the protected resource metadata endpoint + * @param opts.fetchFn - Custom fetch function for HTTP requests + * @returns Authorization server URL, metadata, and resource metadata (if available) + */ +export async function discoverOAuthServerInfo( + serverUrl: string | URL, + opts?: { + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { resourceMetadataUrl: opts?.resourceMetadataUrl }, + opts?.fetchFn + ); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch (error) { + // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are + // transient reachability problems, not "server doesn't support PRM" — propagate so the + // caller sees the real error instead of silently falling back to a different auth server. + if (error instanceof TypeError) { + throw error; + } + // RFC 9728 not supported -- fall back to treating the server URL as the authorization server + } + + // If we don't get a valid authorization server from protected resource metadata, + // fall back to the legacy MCP spec behavior: MCP server base URL acts as the authorization server + if (!authorizationServerUrl) { + authorizationServerUrl = String(new URL('/', serverUrl)); + } + + const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + + return { + authorizationServerUrl, + authorizationServerMetadata, + resourceMetadata + }; +} + +/** + * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. + */ +export async function startAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + redirectUrl, + scope, + state, + resource + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + redirectUrl: string | URL; + scope?: string; + state?: string; + resource?: URL; + } +): Promise<{ authorizationUrl: URL; codeVerifier: string }> { + let authorizationUrl: URL; + if (metadata) { + authorizationUrl = new URL(metadata.authorization_endpoint); + + if (!metadata.response_types_supported.includes(AUTHORIZATION_CODE_RESPONSE_TYPE)) { + throw new Error(`Incompatible auth server: does not support response type ${AUTHORIZATION_CODE_RESPONSE_TYPE}`); + } + + if ( + metadata.code_challenge_methods_supported && + !metadata.code_challenge_methods_supported.includes(AUTHORIZATION_CODE_CHALLENGE_METHOD) + ) { + throw new Error(`Incompatible auth server: does not support code challenge method ${AUTHORIZATION_CODE_CHALLENGE_METHOD}`); + } + } else { + authorizationUrl = new URL('/authorize', authorizationServerUrl); + } + + // Generate PKCE challenge + const challenge = await pkceChallenge(); + const codeVerifier = challenge.code_verifier; + const codeChallenge = challenge.code_challenge; + + authorizationUrl.searchParams.set('response_type', AUTHORIZATION_CODE_RESPONSE_TYPE); + authorizationUrl.searchParams.set('client_id', clientInformation.client_id); + authorizationUrl.searchParams.set('code_challenge', codeChallenge); + authorizationUrl.searchParams.set('code_challenge_method', AUTHORIZATION_CODE_CHALLENGE_METHOD); + authorizationUrl.searchParams.set('redirect_uri', String(redirectUrl)); + + if (state) { + authorizationUrl.searchParams.set('state', state); + } + + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } + + if (scope?.split(' ').includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + authorizationUrl.searchParams.append('prompt', 'consent'); + } + + if (resource) { + authorizationUrl.searchParams.set('resource', resource.href); + } + + return { authorizationUrl, codeVerifier }; +} + +/** + * Prepares token request parameters for an authorization code exchange. + * + * This is the default implementation used by {@linkcode fetchToken} when the provider + * doesn't implement {@linkcode OAuthClientProvider.prepareTokenRequest | prepareTokenRequest}. + * + * @param authorizationCode - The authorization code received from the authorization endpoint + * @param codeVerifier - The PKCE code verifier + * @param redirectUri - The redirect URI used in the authorization request + * @returns URLSearchParams for the `authorization_code` grant + */ +export function prepareAuthorizationCodeRequest( + authorizationCode: string, + codeVerifier: string, + redirectUri: string | URL +): URLSearchParams { + return new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + code_verifier: codeVerifier, + redirect_uri: String(redirectUri) + }); +} + +/** + * Internal helper to execute a token request with the given parameters. + * Used by {@linkcode exchangeAuthorization}, {@linkcode refreshAuthorization}, and {@linkcode fetchToken}. + */ +export async function executeTokenRequest( + authorizationServerUrl: string | URL, + { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + tokenRequestParams: URLSearchParams; + clientInformation?: OAuthClientInformationMixed; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + resource?: URL; + fetchFn?: FetchLike; + } +): Promise { + const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl); + + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }); + + if (resource) { + tokenRequestParams.set('resource', resource.href); + } + + if (addClientAuthentication) { + await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata); + } else if (clientInformation) { + const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams); + } + + const response = await (fetchFn ?? fetch)(tokenUrl, { + method: 'POST', + headers, + body: tokenRequestParams + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + const json: unknown = await response.json(); + + try { + return OAuthTokensSchema.parse(json); + } catch (parseError) { + // Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status. + // Check for error field only if token parsing failed. + if (typeof json === 'object' && json !== null && 'error' in json) { + throw await parseErrorResponse(JSON.stringify(json)); + } + throw parseError; + } +} + +/** + * Exchanges an authorization code for an access token with the given server. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Falls back to appropriate defaults when server metadata is unavailable + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, auth code, etc. + * @returns Promise resolving to OAuth tokens + * @throws {Error} When token exchange fails or authentication is invalid + */ +export async function exchangeAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + authorizationCode, + codeVerifier, + redirectUri, + resource, + addClientAuthentication, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + authorizationCode: string; + codeVerifier: string; + redirectUri: string | URL; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; + } +): Promise { + const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri); + + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn + }); +} + +/** + * Exchange a refresh token for an updated access token. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Preserves the original refresh token if a new one is not returned + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, refresh token, etc. + * @returns Promise resolving to OAuth tokens (preserves original `refresh_token` if not replaced) + * @throws {Error} When token refresh fails or authentication is invalid + */ +export async function refreshAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + refreshToken, + resource, + addClientAuthentication, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + refreshToken: string; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; + } +): Promise { + const tokenRequestParams = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken + }); + + const tokens = await executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn + }); + + // Preserve original refresh token if server didn't return a new one + return { refresh_token: refreshToken, ...tokens }; +} + +/** + * Unified token fetching that works with any grant type via {@linkcode OAuthClientProvider.prepareTokenRequest | prepareTokenRequest()}. + * + * This function provides a single entry point for obtaining tokens regardless of the + * OAuth grant type. The provider's `prepareTokenRequest()` method determines which grant + * to use and supplies the grant-specific parameters. + * + * @param provider - OAuth client provider that implements `prepareTokenRequest()` + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration for the token request + * @returns Promise resolving to OAuth tokens + * @throws {Error} When provider doesn't implement `prepareTokenRequest` or token fetch fails + * + * @example + * ```ts source="./auth.examples.ts#fetchToken_clientCredentials" + * // Provider for client_credentials: + * class MyProvider extends MyProviderBase implements OAuthClientProvider { + * prepareTokenRequest(scope?: string) { + * const params = new URLSearchParams({ grant_type: 'client_credentials' }); + * if (scope) params.set('scope', scope); + * return params; + * } + * } + * + * const tokens = await fetchToken(new MyProvider(), authServerUrl, { metadata }); + * ``` + */ +export async function fetchToken( + provider: OAuthClientProvider, + authorizationServerUrl: string | URL, + { + metadata, + resource, + authorizationCode, + scope, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + resource?: URL; + /** Authorization code for the default `authorization_code` grant flow */ + authorizationCode?: string; + /** Optional scope parameter from auth() options */ + scope?: string; + fetchFn?: FetchLike; + } = {} +): Promise { + // Prefer scope from options, fallback to provider.clientMetadata.scope + const effectiveScope = scope ?? provider.clientMetadata.scope; + + // Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code + let tokenRequestParams: URLSearchParams | undefined; + if (provider.prepareTokenRequest) { + tokenRequestParams = await provider.prepareTokenRequest(effectiveScope); + } + + // Default to authorization_code grant if no custom prepareTokenRequest + if (!tokenRequestParams) { + if (!authorizationCode) { + throw new Error('Either provider.prepareTokenRequest() or authorizationCode is required'); + } + if (!provider.redirectUrl) { + throw new Error('redirectUrl is required for authorization_code flow'); + } + const codeVerifier = await provider.codeVerifier(); + tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); + } + + const clientInformation = await provider.clientInformation(); + + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation: clientInformation ?? undefined, + addClientAuthentication: provider.addClientAuthentication, + resource, + fetchFn + }); +} + +/** + * Performs OAuth 2.0 Dynamic Client Registration according to + * {@link https://datatracker.ietf.org/doc/html/rfc7591 | RFC 7591}. + * + * If `scope` is provided, it overrides `clientMetadata.scope` in the registration + * request body. This allows callers to apply the Scope Selection Strategy (SEP-835) + * consistently across both DCR and the subsequent authorization request. + */ +export async function registerClient( + authorizationServerUrl: string | URL, + { + metadata, + clientMetadata, + scope, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientMetadata: OAuthClientMetadata; + scope?: string; + fetchFn?: FetchLike; + } +): Promise { + let registrationUrl: URL; + + if (metadata) { + if (!metadata.registration_endpoint) { + throw new Error('Incompatible auth server: does not support dynamic client registration'); + } + + registrationUrl = new URL(metadata.registration_endpoint); + } else { + registrationUrl = new URL('/register', authorizationServerUrl); + } + + const response = await (fetchFn ?? fetch)(registrationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + ...clientMetadata, + ...(scope === undefined ? {} : { scope }) + }) + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthClientInformationFullSchema.parse(await response.json()); +} diff --git a/packages/client/src/client/authExtensions.examples.ts b/packages/client/src/client/authExtensions.examples.ts new file mode 100644 index 0000000..bcb26a3 --- /dev/null +++ b/packages/client/src/client/authExtensions.examples.ts @@ -0,0 +1,62 @@ +/** + * Type-checked examples for `authExtensions.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { ClientCredentialsProvider, createPrivateKeyJwtAuth, PrivateKeyJwtProvider } from './authExtensions.js'; +import { StreamableHTTPClientTransport } from './streamableHttp.js'; + +/** + * Example: Creating a private key JWT authentication function. + */ +function createPrivateKeyJwtAuth_basicUsage(pemEncodedPrivateKey: string) { + //#region createPrivateKeyJwtAuth_basicUsage + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'my-client', + subject: 'my-client', + privateKey: pemEncodedPrivateKey, + alg: 'RS256' + }); + // pass addClientAuth as provider.addClientAuthentication implementation + //#endregion createPrivateKeyJwtAuth_basicUsage + return addClientAuth; +} + +/** + * Example: Using ClientCredentialsProvider for OAuth client credentials flow. + */ +function ClientCredentialsProvider_basicUsage(serverUrl: URL) { + //#region ClientCredentialsProvider_basicUsage + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret' + }); + + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: provider + }); + //#endregion ClientCredentialsProvider_basicUsage + return transport; +} + +/** + * Example: Using PrivateKeyJwtProvider for OAuth with private key JWT. + */ +function PrivateKeyJwtProvider_basicUsage(pemEncodedPrivateKey: string, serverUrl: URL) { + //#region PrivateKeyJwtProvider_basicUsage + const provider = new PrivateKeyJwtProvider({ + clientId: 'my-client', + privateKey: pemEncodedPrivateKey, + algorithm: 'RS256' + }); + + const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: provider + }); + //#endregion PrivateKeyJwtProvider_basicUsage + return transport; +} diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts new file mode 100644 index 0000000..cb476c1 --- /dev/null +++ b/packages/client/src/client/authExtensions.ts @@ -0,0 +1,702 @@ +/** + * OAuth provider extensions for specialized authentication flows. + * + * This module provides ready-to-use {@linkcode OAuthClientProvider} implementations + * for common machine-to-machine authentication scenarios. + */ + +import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import type { CryptoKey, JWK } from 'jose'; + +import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; + +/** + * Helper to produce a `private_key_jwt` client authentication function. + * + * @example + * ```ts source="./authExtensions.examples.ts#createPrivateKeyJwtAuth_basicUsage" + * const addClientAuth = createPrivateKeyJwtAuth({ + * issuer: 'my-client', + * subject: 'my-client', + * privateKey: pemEncodedPrivateKey, + * alg: 'RS256' + * }); + * // pass addClientAuth as provider.addClientAuthentication implementation + * ``` + */ +export function createPrivateKeyJwtAuth(options: { + issuer: string; + subject: string; + privateKey: string | Uint8Array | Record; + alg: string; + audience?: string | URL; + lifetimeSeconds?: number; + claims?: Record; +}): AddClientAuthentication { + return async (_headers, params, url, metadata) => { + // Lazy import to avoid heavy dependency unless used + if (globalThis.crypto === undefined) { + throw new TypeError( + 'crypto is not available, please ensure you have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)' + ); + } + + const jose = await import('jose'); + + const audience = String(options.audience ?? metadata?.issuer ?? url); + const lifetimeSeconds = options.lifetimeSeconds ?? 300; + + const now = Math.floor(Date.now() / 1000); + const jti = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const baseClaims = { + iss: options.issuer, + sub: options.subject, + aud: audience, + exp: now + lifetimeSeconds, + iat: now, + jti + }; + const claims = options.claims ? { ...baseClaims, ...options.claims } : baseClaims; + + // Import key for the requested algorithm + const alg = options.alg; + let key: unknown; + if (typeof options.privateKey === 'string') { + if (alg.startsWith('RS') || alg.startsWith('ES') || alg.startsWith('PS')) { + key = await jose.importPKCS8(options.privateKey, alg); + } else if (alg.startsWith('HS')) { + key = new TextEncoder().encode(options.privateKey); + } else { + throw new Error(`Unsupported algorithm ${alg}`); + } + } else if (options.privateKey instanceof Uint8Array) { + // Assume PKCS#8 DER in Uint8Array for asymmetric algorithms + key = alg.startsWith('HS') ? options.privateKey : await jose.importPKCS8(new TextDecoder().decode(options.privateKey), alg); + } else { + // Treat as JWK + key = await jose.importJWK(options.privateKey as JWK, alg); + } + + // Sign JWT + const assertion = await new jose.SignJWT(claims) + .setProtectedHeader({ alg, typ: 'JWT' }) + .setIssuer(options.issuer) + .setSubject(options.subject) + .setAudience(audience) + .setIssuedAt(now) + .setExpirationTime(now + lifetimeSeconds) + .setJti(jti) + .sign(key as unknown as Uint8Array | CryptoKey); + + params.set('client_assertion', assertion); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }; +} + +/** + * Options for creating a {@linkcode ClientCredentialsProvider}. + */ +export interface ClientCredentialsProviderOptions { + /** + * The `client_id` for this OAuth client. + */ + clientId: string; + + /** + * The `client_secret` for `client_secret_basic` authentication. + */ + clientSecret: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; +} + +/** + * OAuth provider for `client_credentials` grant with `client_secret_basic` authentication. + * + * This provider is designed for machine-to-machine authentication where + * the client authenticates using a `client_id` and `client_secret`. + * + * @example + * ```ts source="./authExtensions.examples.ts#ClientCredentialsProvider_basicUsage" + * const provider = new ClientCredentialsProvider({ + * clientId: 'my-client', + * clientSecret: 'my-secret' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class ClientCredentialsProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + + constructor(options: ClientCredentialsProviderOptions) { + this._clientInfo = { + client_id: options.clientId, + client_secret: options.clientSecret + }; + this._clientMetadata = { + client_name: options.clientName ?? 'client-credentials-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic', + scope: options.scope + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} + +/** + * Options for creating a {@linkcode PrivateKeyJwtProvider}. + */ +export interface PrivateKeyJwtProviderOptions { + /** + * The `client_id` for this OAuth client. + */ + clientId: string; + + /** + * The private key for signing JWT assertions. + * Can be a PEM string, Uint8Array, or JWK object. + */ + privateKey: string | Uint8Array | Record; + + /** + * The algorithm to use for signing (e.g., 'RS256', 'ES256'). + */ + algorithm: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Optional JWT lifetime in seconds (default: 300). + */ + jwtLifetimeSeconds?: number; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; + + /** + * Optional custom claims to include in the JWT assertion. + * These are merged with the standard claims (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`), + * with custom claims taking precedence for any overlapping keys. + * + * Useful for including additional claims that help scope the access token + * with finer granularity than what scopes alone allow. + */ + claims?: Record; +} + +/** + * OAuth provider for `client_credentials` grant with `private_key_jwt` authentication. + * + * This provider is designed for machine-to-machine authentication where + * the client authenticates using a signed JWT assertion + * ({@link https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 | RFC 7523 Section 2.2}). + * + * @example + * ```ts source="./authExtensions.examples.ts#PrivateKeyJwtProvider_basicUsage" + * const provider = new PrivateKeyJwtProvider({ + * clientId: 'my-client', + * privateKey: pemEncodedPrivateKey, + * algorithm: 'RS256' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class PrivateKeyJwtProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + addClientAuthentication: AddClientAuthentication; + + constructor(options: PrivateKeyJwtProviderOptions) { + this._clientInfo = { + client_id: options.clientId + }; + this._clientMetadata = { + client_name: options.clientName ?? 'private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope + }; + this.addClientAuthentication = createPrivateKeyJwtAuth({ + issuer: options.clientId, + subject: options.clientId, + privateKey: options.privateKey, + alg: options.algorithm, + lifetimeSeconds: options.jwtLifetimeSeconds, + claims: options.claims + }); + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} + +/** + * Options for creating a {@linkcode StaticPrivateKeyJwtProvider}. + */ +export interface StaticPrivateKeyJwtProviderOptions { + /** + * The `client_id` for this OAuth client. + */ + clientId: string; + + /** + * A pre-built JWT client assertion to use for authentication. + * + * This token should already contain the appropriate claims + * (`iss`, `sub`, `aud`, `exp`, etc.) and be signed by the client's key. + */ + jwtBearerAssertion: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Space-separated scopes values requested by the client. + */ + scope?: string; +} + +/** + * OAuth provider for `client_credentials` grant with a static `private_key_jwt` assertion. + * + * This provider mirrors {@linkcode PrivateKeyJwtProvider} but instead of constructing and + * signing a JWT on each request, it accepts a pre-built JWT assertion string and + * uses it directly for authentication. + */ +export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + addClientAuthentication: AddClientAuthentication; + + constructor(options: StaticPrivateKeyJwtProviderOptions) { + this._clientInfo = { + client_id: options.clientId + }; + this._clientMetadata = { + client_name: options.clientName ?? 'static-private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt', + scope: options.scope + }; + + const assertion = options.jwtBearerAssertion; + this.addClientAuthentication = async (_headers, params) => { + params.set('client_assertion', assertion); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} + +/** + * Context provided to the assertion callback in {@linkcode CrossAppAccessProvider}. + * Contains orchestrator-discovered information needed for JWT Authorization Grant requests. + */ +export interface CrossAppAccessContext { + /** + * The authorization server URL of the target MCP server. + * Discovered via RFC 9728 protected resource metadata. + */ + authorizationServerUrl: string; + + /** + * The resource URL of the target MCP server. + * Discovered via RFC 9728 protected resource metadata. + */ + resourceUrl: string; + + /** + * Optional scope being requested for the MCP server. + */ + scope?: string; + + /** + * Fetch function to use for HTTP requests (e.g., for IdP token exchange). + */ + fetchFn: FetchLike; +} + +/** + * Callback function type that provides a JWT Authorization Grant (ID-JAG). + * + * The callback receives context about the target MCP server (authorization server URL, + * resource URL, scope) and should return a JWT Authorization Grant that will be used + * to obtain an access token from the MCP server. + */ +export type AssertionCallback = (context: CrossAppAccessContext) => string | Promise; + +/** + * Options for creating a {@linkcode CrossAppAccessProvider}. + */ +export interface CrossAppAccessProviderOptions { + /** + * Callback function that provides a JWT Authorization Grant (ID-JAG). + * + * The callback receives the MCP server's authorization server URL, resource URL, + * and requested scope, and should return a JWT Authorization Grant obtained from + * the enterprise IdP via RFC 8693 token exchange. + * + * You can use the utility functions from the `crossAppAccess` module + * for standard flows, or implement custom logic. + * + * @example + * ```ts + * assertion: async (ctx) => { + * const result = await discoverAndRequestJwtAuthGrant({ + * idpUrl: 'https://idp.example.com', + * audience: ctx.authorizationServerUrl, + * resource: ctx.resourceUrl, + * idToken: await getIdToken(), + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: ctx.scope, + * fetchFn: ctx.fetchFn + * }); + * return result.jwtAuthGrant; + * } + * ``` + */ + assertion: AssertionCallback; + + /** + * The `client_id` registered with the MCP server's authorization server. + */ + clientId: string; + + /** + * The `client_secret` for authenticating with the MCP server's authorization server. + */ + clientSecret: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Custom fetch implementation. Defaults to global fetch. + */ + fetchFn?: FetchLike; +} + +/** + * OAuth provider for Cross-App Access (Enterprise Managed Authorization) using JWT Authorization Grant. + * + * This provider implements the Enterprise Managed Authorization flow (SEP-990) where: + * 1. User authenticates with an enterprise IdP and the client obtains an ID Token + * 2. Client exchanges the ID Token for a JWT Authorization Grant (ID-JAG) via RFC 8693 token exchange + * 3. Client uses the JAG to obtain an access token from the MCP server via RFC 7523 JWT bearer grant + * + * The provider handles steps 2-3 automatically, with the JAG acquisition delegated to + * a callback function that you provide. This allows flexibility in how you obtain and + * cache ID Tokens from the IdP. + * + * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + * + * @example + * ```ts + * const provider = new CrossAppAccessProvider({ + * assertion: async (ctx) => { + * const result = await discoverAndRequestJwtAuthGrant({ + * idpUrl: 'https://idp.example.com', + * audience: ctx.authorizationServerUrl, + * resource: ctx.resourceUrl, + * idToken: await getIdToken(), // Your function to get ID token + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: ctx.scope, + * fetchFn: ctx.fetchFn + * }); + * return result.jwtAuthGrant; + * }, + * clientId: 'my-mcp-client', + * clientSecret: 'my-mcp-secret' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + * ``` + */ +export class CrossAppAccessProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + private _assertionCallback: AssertionCallback; + private _fetchFn: FetchLike; + private _authorizationServerUrl?: string; + private _resourceUrl?: string; + private _scope?: string; + + constructor(options: CrossAppAccessProviderOptions) { + this._clientInfo = { + client_id: options.clientId, + client_secret: options.clientSecret + }; + this._clientMetadata = { + client_name: options.clientName ?? 'cross-app-access-client', + redirect_uris: [], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + token_endpoint_auth_method: 'client_secret_basic' + }; + this._assertionCallback = options.assertion; + this._fetchFn = options.fetchFn ?? fetch; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for jwt-bearer flow'); + } + + saveCodeVerifier(): void { + // Not used for jwt-bearer + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for jwt-bearer flow'); + } + + /** + * Saves the authorization server URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveAuthorizationServerUrl?(authorizationServerUrl: string): void { + this._authorizationServerUrl = authorizationServerUrl; + } + + /** + * Returns the cached authorization server URL if available. + */ + authorizationServerUrl?(): string | undefined { + return this._authorizationServerUrl; + } + + /** + * Saves the resource URL discovered during OAuth flow. + * This is called by the auth() function after RFC 9728 discovery. + */ + saveResourceUrl?(resourceUrl: string): void { + this._resourceUrl = resourceUrl; + } + + /** + * Returns the cached resource URL if available. + */ + resourceUrl?(): string | undefined { + return this._resourceUrl; + } + + async prepareTokenRequest(scope?: string): Promise { + // Get the authorization server URL and resource URL from cached state + const authServerUrl = this._authorizationServerUrl; + const resourceUrl = this._resourceUrl; + + if (!authServerUrl) { + throw new Error('Authorization server URL not available. Ensure auth() has been called first.'); + } + + if (!resourceUrl) { + throw new Error( + 'Resource URL not available — server may not implement RFC 9728 ' + + 'Protected Resource Metadata (required for Cross-App Access), or ' + + 'auth() has not been called' + ); + } + + // Store scope for assertion callback + this._scope = scope; + + // Call the assertion callback to get the JWT Authorization Grant + const jwtAuthGrant = await this._assertionCallback({ + authorizationServerUrl: authServerUrl, + resourceUrl: resourceUrl, + scope: this._scope, + fetchFn: this._fetchFn + }); + + // Return params for JWT bearer grant per RFC 7523 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtAuthGrant + }); + + if (scope) { + params.set('scope', scope); + } + + return params; + } +} diff --git a/packages/client/src/client/client.examples.ts b/packages/client/src/client/client.examples.ts new file mode 100644 index 0000000..b08694c --- /dev/null +++ b/packages/client/src/client/client.examples.ts @@ -0,0 +1,194 @@ +/** + * Type-checked examples for `client.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core'; + +import { Client } from './client.js'; +import { SSEClientTransport } from './sse.js'; +import { StdioClientTransport } from './stdio.js'; +import { StreamableHTTPClientTransport } from './streamableHttp.js'; + +/** + * Example: Using listChanged to automatically track tool and prompt updates. + */ +function ClientOptions_listChanged() { + //#region ClientOptions_listChanged + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + { + listChanged: { + tools: { + onChanged: (error, tools) => { + if (error) { + console.error('Failed to refresh tools:', error); + return; + } + console.log('Tools updated:', tools); + } + }, + prompts: { + onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + } + } + } + ); + //#endregion ClientOptions_listChanged + return client; +} + +/** + * Example: Connect to a local server process over stdio. + */ +async function Client_connect_stdio() { + //#region Client_connect_stdio + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StdioClientTransport({ command: 'my-mcp-server' }); + await client.connect(transport); + //#endregion Client_connect_stdio + return client; +} + +/** + * Example: Connect with Streamable HTTP, falling back to legacy SSE. + */ +async function Client_connect_sseFallback(url: string) { + //#region Client_connect_sseFallback + const baseUrl = new URL(url); + + try { + // Try modern Streamable HTTP transport first + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } catch { + // Fall back to legacy SSE transport + const client = new Client({ name: 'my-client', version: '1.0.0' }); + const transport = new SSEClientTransport(baseUrl); + await client.connect(transport); + return { client, transport }; + } + //#endregion Client_connect_sseFallback +} + +/** + * Example: Call a tool on the connected server. + */ +async function Client_callTool_basic(client: Client) { + //#region Client_callTool_basic + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } + }); + + // Tool-level errors are returned in the result, not thrown + if (result.isError) { + console.error('Tool error:', result.content); + return; + } + + console.log(result.content); + //#endregion Client_callTool_basic +} + +/** + * Example: Access machine-readable structured output from a tool call. + */ +async function Client_callTool_structuredOutput(client: Client) { + //#region Client_callTool_structuredOutput + const result = await client.callTool({ + name: 'calculate-bmi', + arguments: { weightKg: 70, heightM: 1.75 } + }); + + // Machine-readable output for the client application + if (result.structuredContent) { + console.log(result.structuredContent); // e.g. { bmi: 22.86 } + } + //#endregion Client_callTool_structuredOutput +} + +/** + * Example: Handle a sampling request from the server. + */ +function Client_setRequestHandler_sampling(client: Client) { + //#region Client_setRequestHandler_sampling + client.setRequestHandler('sampling/createMessage', async request => { + const lastMessage = request.params.messages.at(-1); + console.log('Sampling request:', lastMessage); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { + type: 'text' as const, + text: 'Response from the model' + } + }; + }); + //#endregion Client_setRequestHandler_sampling +} + +/** + * Example: List tools with cursor-based pagination. + */ +async function Client_listTools_pagination(client: Client) { + //#region Client_listTools_pagination + const allTools: Tool[] = []; + let cursor: string | undefined; + do { + const { tools, nextCursor } = await client.listTools({ cursor }); + allTools.push(...tools); + cursor = nextCursor; + } while (cursor); + console.log( + 'Available tools:', + allTools.map(t => t.name) + ); + //#endregion Client_listTools_pagination +} + +/** + * Example: List prompts with cursor-based pagination. + */ +async function Client_listPrompts_pagination(client: Client) { + //#region Client_listPrompts_pagination + const allPrompts: Prompt[] = []; + let cursor: string | undefined; + do { + const { prompts, nextCursor } = await client.listPrompts({ cursor }); + allPrompts.push(...prompts); + cursor = nextCursor; + } while (cursor); + console.log( + 'Available prompts:', + allPrompts.map(p => p.name) + ); + //#endregion Client_listPrompts_pagination +} + +/** + * Example: List resources with cursor-based pagination. + */ +async function Client_listResources_pagination(client: Client) { + //#region Client_listResources_pagination + const allResources: Resource[] = []; + let cursor: string | undefined; + do { + const { resources, nextCursor } = await client.listResources({ cursor }); + allResources.push(...resources); + cursor = nextCursor; + } while (cursor); + console.log( + 'Available resources:', + allResources.map(r => r.name) + ); + //#endregion Client_listResources_pagination +} diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts new file mode 100644 index 0000000..5fa2e14 --- /dev/null +++ b/packages/client/src/client/client.ts @@ -0,0 +1,1060 @@ +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; +import type { + BaseContext, + CallToolRequest, + ClientCapabilities, + ClientContext, + ClientNotification, + ClientRequest, + CompleteRequest, + GetPromptRequest, + Implementation, + JSONRPCRequest, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + ListChangedHandlers, + ListChangedOptions, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListToolsRequest, + LoggingLevel, + MessageExtraInfo, + NotificationMethod, + ProtocolOptions, + ReadResourceRequest, + RequestMethod, + RequestOptions, + Result, + ServerCapabilities, + SubscribeRequest, + TaskManagerOptions, + Tool, + Transport, + UnsubscribeRequest +} from '@modelcontextprotocol/core'; +import { + assertClientRequestTaskCapability, + assertToolsCallTaskCapability, + CallToolResultSchema, + CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, + ElicitRequestSchema, + ElicitResultSchema, + EmptyResultSchema, + extractTaskManagerOptions, + GetPromptResultSchema, + InitializeResultSchema, + LATEST_PROTOCOL_VERSION, + ListChangedOptionsBaseSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + mergeCapabilities, + parseSchema, + Protocol, + ProtocolError, + ProtocolErrorCode, + ReadResourceResultSchema, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; + +import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; + +/** + * Elicitation default application helper. Applies defaults to the `data` based on the `schema`. + * + * @param schema - The schema to apply defaults to. + * @param data - The data to apply defaults to. + */ +function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unknown): void { + if (!schema || data === null || typeof data !== 'object') return; + + // Handle object properties + if (schema.type === 'object' && schema.properties && typeof schema.properties === 'object') { + const obj = data as Record; + const props = schema.properties as Record; + for (const key of Object.keys(props)) { + const propSchema = props[key]!; + // If missing or explicitly undefined, apply default if present + if (obj[key] === undefined && Object.prototype.hasOwnProperty.call(propSchema, 'default')) { + obj[key] = propSchema.default; + } + // Recurse into existing nested objects/arrays + if (obj[key] !== undefined) { + applyElicitationDefaults(propSchema, obj[key]); + } + } + } + + if (Array.isArray(schema.anyOf)) { + for (const sub of schema.anyOf) { + // Skip boolean schemas (true/false are valid JSON Schemas but have no defaults) + if (typeof sub !== 'boolean') { + applyElicitationDefaults(sub, data); + } + } + } + + // Combine schemas + if (Array.isArray(schema.oneOf)) { + for (const sub of schema.oneOf) { + // Skip boolean schemas (true/false are valid JSON Schemas but have no defaults) + if (typeof sub !== 'boolean') { + applyElicitationDefaults(sub, data); + } + } + } +} + +/** + * Determines which elicitation modes are supported based on declared client capabilities. + * + * According to the spec: + * - An empty elicitation capability object defaults to form mode support (backwards compatibility) + * - URL mode is only supported if explicitly declared + * + * @param capabilities - The client's elicitation capabilities + * @returns An object indicating which modes are supported + */ +export function getSupportedElicitationModes(capabilities: ClientCapabilities['elicitation']): { + supportsFormMode: boolean; + supportsUrlMode: boolean; +} { + if (!capabilities) { + return { supportsFormMode: false, supportsUrlMode: false }; + } + + const hasFormCapability = capabilities.form !== undefined; + const hasUrlCapability = capabilities.url !== undefined; + + // If neither form nor url are explicitly declared, form mode is supported (backwards compatibility) + const supportsFormMode = hasFormCapability || (!hasFormCapability && !hasUrlCapability); + const supportsUrlMode = hasUrlCapability; + + return { supportsFormMode, supportsUrlMode }; +} + +/** + * Extended tasks capability that includes runtime configuration (store, messageQueue). + * The runtime-only fields are stripped before advertising capabilities to servers. + */ +export type ClientTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; + +export type ClientOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this client. + */ + capabilities?: Omit & { + tasks?: ClientTasksCapabilityWithRuntime; + }; + + /** + * JSON Schema validator for tool output validation. + * + * The validator is used to validate structured content returned by tools + * against their declared output schemas. + * + * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) + */ + jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Configure handlers for list changed notifications (tools, prompts, resources). + * + * @example + * ```ts source="./client.examples.ts#ClientOptions_listChanged" + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * listChanged: { + * tools: { + * onChanged: (error, tools) => { + * if (error) { + * console.error('Failed to refresh tools:', error); + * return; + * } + * console.log('Tools updated:', tools); + * } + * }, + * prompts: { + * onChanged: (error, prompts) => console.log('Prompts updated:', prompts) + * } + * } + * } + * ); + * ``` + */ + listChanged?: ListChangedHandlers; +}; + +/** + * An MCP client on top of a pluggable transport. + * + * The client will automatically begin the initialization flow with the server when {@linkcode connect} is called. + * + * To handle server-initiated requests (sampling, elicitation, roots), call {@linkcode setRequestHandler}. + * The client must declare the corresponding capability for the handler to be accepted. For + * `sampling/createMessage` and `elicitation/create`, the handler is automatically wrapped with + * schema validation for both the incoming request and the returned result. + * + * @example Handling a sampling request + * ```ts source="./client.examples.ts#Client_setRequestHandler_sampling" + * client.setRequestHandler('sampling/createMessage', async request => { + * const lastMessage = request.params.messages.at(-1); + * console.log('Sampling request:', lastMessage); + * + * // In production, send messages to your LLM here + * return { + * model: 'my-model', + * role: 'assistant' as const, + * content: { + * type: 'text' as const, + * text: 'Response from the model' + * } + * }; + * }); + * ``` + */ +export class Client extends Protocol { + private _serverCapabilities?: ServerCapabilities; + private _serverVersion?: Implementation; + private _negotiatedProtocolVersion?: string; + private _capabilities: ClientCapabilities; + private _instructions?: string; + private _jsonSchemaValidator: jsonSchemaValidator; + private _cachedToolOutputValidators: Map> = new Map(); + private _cachedKnownTaskTools: Set = new Set(); + private _cachedRequiredTaskTools: Set = new Set(); + private _experimental?: { tasks: ExperimentalClientTasks }; + private _listChangedDebounceTimers: Map> = new Map(); + private _pendingListChangedConfig?: ListChangedHandlers; + private _enforceStrictCapabilities: boolean; + + /** + * Initializes this client with the given name and version information. + */ + constructor( + private _clientInfo: Implementation, + options?: ClientOptions + ) { + super({ + ...options, + tasks: extractTaskManagerOptions(options?.capabilities?.tasks) + }); + this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false; + + // Strip runtime-only fields from advertised capabilities + if (options?.capabilities?.tasks) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = + options.capabilities.tasks; + this._capabilities.tasks = wireCapabilities; + } + + // Store list changed config for setup after connection (when we know server capabilities) + if (options?.listChanged) { + this._pendingListChangedConfig = options.listChanged; + } + } + + protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext { + return ctx; + } + + /** + * Set up handlers for list changed notifications based on config and server capabilities. + * This should only be called after initialization when server capabilities are known. + * Handlers are silently skipped if the server doesn't advertise the corresponding listChanged capability. + * @internal + */ + private _setupListChangedHandlers(config: ListChangedHandlers): void { + if (config.tools && this._serverCapabilities?.tools?.listChanged) { + this._setupListChangedHandler('tools', 'notifications/tools/list_changed', config.tools, async () => { + const result = await this.listTools(); + return result.tools; + }); + } + + if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { + this._setupListChangedHandler('prompts', 'notifications/prompts/list_changed', config.prompts, async () => { + const result = await this.listPrompts(); + return result.prompts; + }); + } + + if (config.resources && this._serverCapabilities?.resources?.listChanged) { + this._setupListChangedHandler('resources', 'notifications/resources/list_changed', config.resources, async () => { + const result = await this.listResources(); + return result.resources; + }); + } + } + + /** + * Access experimental features. + * + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + get experimental(): { tasks: ExperimentalClientTasks } { + if (!this._experimental) { + this._experimental = { + tasks: new ExperimentalClientTasks(this) + }; + } + return this._experimental; + } + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + public registerCapabilities(capabilities: ClientCapabilities): void { + if (this.transport) { + throw new Error('Cannot register capabilities after connecting to transport'); + } + + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + /** + * Enforces client-side validation for `elicitation/create` and `sampling/createMessage` + * regardless of how the handler was registered. + */ + protected override _wrapHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: ClientContext) => Promise + ): (request: JSONRPCRequest, ctx: ClientContext) => Promise { + if (method === 'elicitation/create') { + return async (request, ctx) => { + const validatedRequest = parseSchema(ElicitRequestSchema, request); + if (!validatedRequest.success) { + // Type guard: if success is false, error is guaranteed to exist + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`); + } + + const { params } = validatedRequest.data; + params.mode = params.mode ?? 'form'; + const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); + + if (params.mode === 'form' && !supportsFormMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } + + if (params.mode === 'url' && !supportsUrlMode) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); + } + + const result = await handler(request, ctx); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against ElicitResultSchema + const validationResult = parseSchema(ElicitResultSchema, result); + if (!validationResult.success) { + // Type guard: if success is false, error is guaranteed to exist + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`); + } + + const validatedResult = validationResult.data; + const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; + + if ( + params.mode === 'form' && + validatedResult.action === 'accept' && + validatedResult.content && + requestedSchema && + this._capabilities.elicitation?.form?.applyDefaults + ) { + try { + applyElicitationDefaults(requestedSchema, validatedResult.content); + } catch { + // gracefully ignore errors in default application + } + } + + return validatedResult; + }; + } + + if (method === 'sampling/createMessage') { + return async (request, ctx) => { + const validatedRequest = parseSchema(CreateMessageRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); + } + + const { params } = validatedRequest.data; + + const result = await handler(request, ctx); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against appropriate schema based on tools presence + const hasTools = params.tools || params.toolChoice; + const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema; + const validationResult = parseSchema(resultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); + } + + return validationResult.data; + }; + } + + return handler; + } + + protected assertCapability(capability: keyof ServerCapabilities, method: string): void { + if (!this._serverCapabilities?.[capability]) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support ${capability} (required for ${method})`); + } + } + + /** + * Connects to a server via the given transport and performs the MCP initialization handshake. + * + * @example Basic usage (stdio) + * ```ts source="./client.examples.ts#Client_connect_stdio" + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StdioClientTransport({ command: 'my-mcp-server' }); + * await client.connect(transport); + * ``` + * + * @example Streamable HTTP with SSE fallback + * ```ts source="./client.examples.ts#Client_connect_sseFallback" + * const baseUrl = new URL(url); + * + * try { + * // Try modern Streamable HTTP transport first + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StreamableHTTPClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } catch { + * // Fall back to legacy SSE transport + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new SSEClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } + * ``` + */ + override async connect(transport: Transport, options?: RequestOptions): Promise { + await super.connect(transport); + // When transport sessionId is already set this means we are trying to reconnect. + // Restore the protocol version negotiated during the original initialize handshake + // so HTTP transports include the required mcp-protocol-version header, but skip re-init. + if (transport.sessionId !== undefined) { + if (this._negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) { + transport.setProtocolVersion(this._negotiatedProtocolVersion); + } + return; + } + try { + const result = await this._requestWithSchema( + { + method: 'initialize', + params: { + protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); + + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + + if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + this._negotiatedProtocolVersion = result.protocolVersion; + // HTTP transports must set the protocol version in each header after initialization. + if (transport.setProtocolVersion) { + transport.setProtocolVersion(result.protocolVersion); + } + + this._instructions = result.instructions; + + await this.notification({ + method: 'notifications/initialized' + }); + + // Set up list changed handlers now that we know server capabilities + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + } catch (error) { + // Disconnect if initialization fails. + void this.close(); + throw error; + } + } + + /** + * After initialization has completed, this will be populated with the server's reported capabilities. + */ + getServerCapabilities(): ServerCapabilities | undefined { + return this._serverCapabilities; + } + + /** + * After initialization has completed, this will be populated with information about the server's name and version. + */ + getServerVersion(): Implementation | undefined { + return this._serverVersion; + } + + /** + * After initialization has completed, this will be populated with the protocol version negotiated + * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this + * value to the new transport so it continues sending the required `mcp-protocol-version` header. + */ + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + + /** + * After initialization has completed, this may be populated with information about the server's instructions. + */ + getInstructions(): string | undefined { + return this._instructions; + } + + protected assertCapabilityForMethod(method: RequestMethod | string): void { + switch (method as ClientRequest['method']) { + case 'logging/setLevel': { + if (!this._serverCapabilities?.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + } + + case 'prompts/get': + case 'prompts/list': { + if (!this._serverCapabilities?.prompts) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); + } + break; + } + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': + case 'resources/subscribe': + case 'resources/unsubscribe': { + if (!this._serverCapabilities?.resources) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); + } + + if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support resource subscriptions (required for ${method})` + ); + } + + break; + } + + case 'tools/call': + case 'tools/list': { + if (!this._serverCapabilities?.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); + } + break; + } + + case 'completion/complete': { + if (!this._serverCapabilities?.completions) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); + } + break; + } + + case 'initialize': { + // No specific capability required for initialize + break; + } + + case 'ping': { + // No specific capability required for ping + break; + } + } + } + + protected assertNotificationCapability(method: NotificationMethod | string): void { + switch (method as ClientNotification['method']) { + case 'notifications/roots/list_changed': { + if (!this._capabilities.roots?.listChanged) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support roots list changed notifications (required for ${method})` + ); + } + break; + } + + case 'notifications/initialized': { + // No specific capability required for initialized + break; + } + + case 'notifications/cancelled': { + // Cancellation notifications are always allowed + break; + } + + case 'notifications/progress': { + // Progress notifications are always allowed + break; + } + } + } + + protected assertRequestHandlerCapability(method: string): void { + switch (method) { + case 'sampling/createMessage': { + if (!this._capabilities.sampling) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support sampling capability (required for ${method})` + ); + } + break; + } + + case 'elicitation/create': { + if (!this._capabilities.elicitation) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support elicitation capability (required for ${method})` + ); + } + break; + } + + case 'roots/list': { + if (!this._capabilities.roots) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support roots capability (required for ${method})` + ); + } + break; + } + + case 'ping': { + // No specific capability required for ping + break; + } + } + } + + protected assertTaskCapability(method: string): void { + assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server'); + } + + protected assertTaskHandlerCapability(method: string): void { + assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client'); + } + + async ping(options?: RequestOptions) { + return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options); + } + + /** Requests argument autocompletion suggestions from the server for a prompt or resource. */ + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + + /** Sets the minimum severity level for log messages sent by the server. */ + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + + /** Retrieves a prompt by name from the server, passing the given arguments for template substitution. */ + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + + /** + * Lists available prompts. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise prompts capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listPrompts_pagination" + * const allPrompts: Prompt[] = []; + * let cursor: string | undefined; + * do { + * const { prompts, nextCursor } = await client.listPrompts({ cursor }); + * allPrompts.push(...prompts); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available prompts:', + * allPrompts.map(p => p.name) + * ); + * ``` + */ + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.prompts && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support prompts + console.debug('Client.listPrompts() called but server does not advertise prompts capability - returning empty list'); + return { prompts: [] }; + } + return this._requestWithSchema({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + + /** + * Lists available resources. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise resources capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listResources_pagination" + * const allResources: Resource[] = []; + * let cursor: string | undefined; + * do { + * const { resources, nextCursor } = await client.listResources({ cursor }); + * allResources.push(...resources); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available resources:', + * allResources.map(r => r.name) + * ); + * ``` + */ + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug('Client.listResources() called but server does not advertise resources capability - returning empty list'); + return { resources: [] }; + } + return this._requestWithSchema({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + + /** + * Lists available resource URI templates for dynamic resources. Results may be paginated — see {@linkcode listResources | listResources()} for the cursor pattern. + * + * Returns an empty list if the server does not advertise resources capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + */ + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.resources && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support resources + console.debug( + 'Client.listResourceTemplates() called but server does not advertise resources capability - returning empty list' + ); + return { resourceTemplates: [] }; + } + return this._requestWithSchema({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + + /** Reads the contents of a resource by URI. */ + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + + /** Subscribes to change notifications for a resource. The server must support resource subscriptions. */ + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + } + + /** Unsubscribes from change notifications for a resource. */ + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + } + + /** + * Calls a tool on the connected server and returns the result. Automatically validates structured output + * if the tool has an `outputSchema`. + * + * Tool results have two error surfaces: `result.isError` for tool-level failures (the tool ran but reported + * a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for + * SDK-level issues (timeouts, missing capabilities). + * + * For task-based execution with streaming behavior, use {@linkcode ExperimentalClientTasks.callToolStream | client.experimental.tasks.callToolStream()} instead. + * + * @example Basic usage + * ```ts source="./client.examples.ts#Client_callTool_basic" + * const result = await client.callTool({ + * name: 'calculate-bmi', + * arguments: { weightKg: 70, heightM: 1.75 } + * }); + * + * // Tool-level errors are returned in the result, not thrown + * if (result.isError) { + * console.error('Tool error:', result.content); + * return; + * } + * + * console.log(result.content); + * ``` + * + * @example Structured output + * ```ts source="./client.examples.ts#Client_callTool_structuredOutput" + * const result = await client.callTool({ + * name: 'calculate-bmi', + * arguments: { weightKg: 70, heightM: 1.75 } + * }); + * + * // Machine-readable output for the client application + * if (result.structuredContent) { + * console.log(result.structuredContent); // e.g. { bmi: 22.86 } + * } + * ``` + */ + async callTool(params: CallToolRequest['params'], options?: RequestOptions) { + // Guard: required-task tools need experimental API + if (this.isToolTaskRequired(params.name)) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` + ); + } + + const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options); + + // Check if the tool has an outputSchema + const validator = this.getToolOutputValidator(params.name); + if (validator) { + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ); + } + + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content against the schema + const validationResult = validator(result.structuredContent); + + if (!validationResult.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + return result; + } + + private isToolTask(toolName: string): boolean { + if (!this._serverCapabilities?.tasks?.requests?.tools?.call) { + return false; + } + + return this._cachedKnownTaskTools.has(toolName); + } + + /** + * Check if a tool requires task-based execution. + * Unlike {@linkcode isToolTask} which includes `'optional'` tools, this only checks for `'required'`. + */ + private isToolTaskRequired(toolName: string): boolean { + return this._cachedRequiredTaskTools.has(toolName); + } + + /** + * Cache validators for tool output schemas. + * Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance. + */ + private cacheToolMetadata(tools: Tool[]): void { + this._cachedToolOutputValidators.clear(); + this._cachedKnownTaskTools.clear(); + this._cachedRequiredTaskTools.clear(); + + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType); + this._cachedToolOutputValidators.set(tool.name, toolValidator); + } + + // If the tool supports task-based execution, cache that information + const taskSupport = tool.execution?.taskSupport; + if (taskSupport === 'required' || taskSupport === 'optional') { + this._cachedKnownTaskTools.add(tool.name); + } + if (taskSupport === 'required') { + this._cachedRequiredTaskTools.add(tool.name); + } + } + } + + /** + * Get cached validator for a tool + */ + private getToolOutputValidator(toolName: string): JsonSchemaValidator | undefined { + return this._cachedToolOutputValidators.get(toolName); + } + + /** + * Lists available tools. Results may be paginated — loop on `nextCursor` to collect all pages. + * + * Returns an empty list if the server does not advertise tools capability + * (or throws if {@linkcode ClientOptions.enforceStrictCapabilities} is enabled). + * + * @example + * ```ts source="./client.examples.ts#Client_listTools_pagination" + * const allTools: Tool[] = []; + * let cursor: string | undefined; + * do { + * const { tools, nextCursor } = await client.listTools({ cursor }); + * allTools.push(...tools); + * cursor = nextCursor; + * } while (cursor); + * console.log( + * 'Available tools:', + * allTools.map(t => t.name) + * ); + * ``` + */ + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + if (!this._serverCapabilities?.tools && !this._enforceStrictCapabilities) { + // Respect capability negotiation: server does not support tools + console.debug('Client.listTools() called but server does not advertise tools capability - returning empty list'); + return { tools: [] }; + } + const result = await this._requestWithSchema({ method: 'tools/list', params }, ListToolsResultSchema, options); + + // Cache the tools and their output schemas for future validation + this.cacheToolMetadata(result.tools); + + return result; + } + + /** + * Set up a single list changed handler. + * @internal + */ + private _setupListChangedHandler( + listType: string, + notificationMethod: NotificationMethod, + options: ListChangedOptions, + fetcher: () => Promise + ): void { + // Validate options using Zod schema (validates autoRefresh and debounceMs) + const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); + if (!parseResult.success) { + throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); + } + + // Validate callback + if (typeof options.onChanged !== 'function') { + throw new TypeError(`Invalid ${listType} listChanged options: onChanged must be a function`); + } + + const { autoRefresh, debounceMs } = parseResult.data; + const { onChanged } = options; + + const refresh = async () => { + if (!autoRefresh) { + onChanged(null, null); + return; + } + + try { + const items = await fetcher(); + onChanged(null, items); + } catch (error) { + const newError = error instanceof Error ? error : new Error(String(error)); + onChanged(newError, null); + } + }; + + const handler = () => { + if (debounceMs) { + // Clear any pending debounce timer for this list type + const existingTimer = this._listChangedDebounceTimers.get(listType); + if (existingTimer) { + clearTimeout(existingTimer); + } + + // Set up debounced refresh + const timer = setTimeout(refresh, debounceMs); + this._listChangedDebounceTimers.set(listType, timer); + } else { + // No debounce, refresh immediately + refresh(); + } + }; + + // Register notification handler + this.setNotificationHandler(notificationMethod, handler); + } + + /** Notifies the server that the client's root list has changed. Requires the `roots.listChanged` capability. */ + async sendRootsListChanged() { + return this.notification({ method: 'notifications/roots/list_changed' }); + } +} diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts new file mode 100644 index 0000000..9e0219d --- /dev/null +++ b/packages/client/src/client/crossAppAccess.ts @@ -0,0 +1,303 @@ +/** + * Cross-App Access (Enterprise Managed Authorization) Layer 2 utilities. + * + * Provides standalone functions for RFC 8693 Token Exchange and RFC 7523 JWT Authorization Grant + * flows as specified in the Enterprise Managed Authorization specification (SEP-990). + * + * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx + * @module + */ + +import type { FetchLike } from '@modelcontextprotocol/core'; +import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core'; + +import type { ClientAuthMethod } from './auth.js'; +import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth.js'; + +/** + * Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange. + */ +export interface RequestJwtAuthGrantOptions { + /** + * The IdP's token endpoint URL where the token exchange request will be sent. + */ + tokenEndpoint: string | URL; + + /** + * The authorization server URL of the target MCP server (used as `audience` in the token exchange request). + */ + audience: string | URL; + + /** + * The resource identifier of the target MCP server (RFC 9728). + */ + resource: string | URL; + + /** + * The identity assertion (ID Token) from the enterprise IdP. + * This should be the OpenID Connect ID Token obtained during user authentication. + */ + idToken: string; + + /** + * The client ID registered with the IdP for token exchange. + */ + clientId: string; + + /** + * The client secret for authenticating with the IdP. + * + * Optional: the IdP may register the MCP client as a public client. RFC 8693 does + * not mandate confidential clients for token exchange. Omitting this parameter + * omits `client_secret` from the request body. + */ + clientSecret?: string; + + /** + * Optional space-separated list of scopes to request for the target MCP server. + */ + scope?: string; + + /** + * Custom fetch implementation. Defaults to global fetch. + */ + fetchFn?: FetchLike; +} + +/** + * Options for discovering the IdP's token endpoint and requesting a JWT Authorization Grant. + * Extends {@linkcode RequestJwtAuthGrantOptions} with IdP discovery. + */ +export interface DiscoverAndRequestJwtAuthGrantOptions extends Omit { + /** + * The IdP's issuer URL for OAuth metadata discovery. + * Will be used to discover the token endpoint via `.well-known/oauth-authorization-server`. + */ + idpUrl: string | URL; +} + +/** + * Result from a successful JWT Authorization Grant token exchange. + */ +export interface JwtAuthGrantResult { + /** + * The JWT Authorization Grant (ID-JAG) that can be used to request an access token from the MCP server. + */ + jwtAuthGrant: string; + + /** + * Optional expiration time in seconds for the JWT Authorization Grant. + */ + expiresIn?: number; + + /** + * Optional scope granted by the IdP (may differ from requested scope). + */ + scope?: string; +} + +/** + * Requests a JWT Authorization Grant (ID-JAG) from an enterprise IdP using RFC 8693 Token Exchange. + * + * This function performs step 2 of the Enterprise Managed Authorization flow: + * exchanges an ID Token for a JWT Authorization Grant that can be used with the target MCP server. + * + * @param options - Configuration for the token exchange request + * @returns The JWT Authorization Grant and related metadata + * @throws {Error} If the token exchange fails or returns an error response + * + * @example + * ```ts + * const result = await requestJwtAuthorizationGrant({ + * tokenEndpoint: 'https://idp.example.com/token', + * audience: 'https://auth.chat.example/', + * resource: 'https://mcp.chat.example/', + * idToken: 'eyJhbGciOiJS...', + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret', + * scope: 'chat.read chat.history' + * }); + * + * // Use result.jwtAuthGrant with the MCP server's authorization server + * ``` + */ +export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise { + const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options; + + // Prepare token exchange request per RFC 8693 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + audience: String(audience), + resource: String(resource), + subject_token: idToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', + client_id: clientId + }); + + // Only include client_secret when provided — sending an empty/undefined secret + // triggers `invalid_client` on strict IdPs that registered this as a public client. + if (clientSecret) { + params.set('client_secret', clientSecret); + } + + if (scope) { + params.set('scope', scope); + } + + const response = await fetchFn(String(tokenEndpoint), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + // Try to parse as OAuth error response + const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); + if (parseResult.success) { + const { error, error_description } = parseResult.data; + throw new Error(`Token exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); + } + + throw new Error(`Token exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + } + + const parseResult = IdJagTokenExchangeResponseSchema.safeParse(await response.json()); + if (!parseResult.success) { + throw new Error(`Invalid token exchange response: ${parseResult.error.message}`); + } + + return { + jwtAuthGrant: parseResult.data.access_token, + expiresIn: parseResult.data.expires_in, + scope: parseResult.data.scope + }; +} + +/** + * Discovers the IdP's token endpoint and requests a JWT Authorization Grant. + * + * This is a convenience wrapper around {@linkcode requestJwtAuthorizationGrant} that + * first performs OAuth metadata discovery to find the token endpoint. + * + * @param options - Configuration including IdP URL for discovery + * @returns The JWT Authorization Grant and related metadata + * @throws {Error} If discovery fails or the token exchange fails + * + * @example + * ```ts + * const result = await discoverAndRequestJwtAuthGrant({ + * idpUrl: 'https://idp.example.com', + * audience: 'https://auth.chat.example/', + * resource: 'https://mcp.chat.example/', + * idToken: await getIdToken(), + * clientId: 'my-idp-client', + * clientSecret: 'my-idp-secret' + * }); + * ``` + */ +export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { + const { idpUrl, fetchFn = fetch, ...restOptions } = options; + + // Discover IdP's authorization server metadata + const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn }); + + if (!metadata?.token_endpoint) { + throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`); + } + + // Perform token exchange + return requestJwtAuthorizationGrant({ + ...restOptions, + tokenEndpoint: metadata.token_endpoint, + fetchFn + }); +} + +/** + * Exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server. + * + * This function performs step 3 of the Enterprise Managed Authorization flow: + * uses the JWT Authorization Grant to obtain an access token from the MCP server. + * + * @param options - Configuration for the JWT grant exchange + * @returns OAuth tokens (access token, token type, etc.) + * @throws {Error} If the exchange fails or returns an error response + * + * Defaults to `client_secret_basic` (HTTP Basic Authorization header), matching + * `CrossAppAccessProvider`'s declared `token_endpoint_auth_method` and the + * SEP-990 conformance test requirements. Use `authMethod: 'client_secret_post'` only + * when the authorization server explicitly requires it. + * + * @example + * ```ts + * const tokens = await exchangeJwtAuthGrant({ + * tokenEndpoint: 'https://auth.chat.example/token', + * jwtAuthGrant: 'eyJhbGci...', + * clientId: 'my-mcp-client', + * clientSecret: 'my-mcp-secret' + * }); + * + * // Use tokens.access_token to access the MCP server + * ``` + */ +export async function exchangeJwtAuthGrant(options: { + tokenEndpoint: string | URL; + jwtAuthGrant: string; + clientId: string; + clientSecret?: string; + /** + * Client authentication method. Defaults to `'client_secret_basic'` to align with + * `CrossAppAccessProvider` and SEP-990 conformance requirements. + * Callers with no `clientSecret` should pass `'none'` for public-client auth. + */ + authMethod?: ClientAuthMethod; + fetchFn?: FetchLike; +}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> { + const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options; + + // Prepare JWT bearer grant request per RFC 7523 + const params = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwtAuthGrant + }); + + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params); + + const response = await fetchFn(String(tokenEndpoint), { + method: 'POST', + headers, + body: params.toString() + }); + + if (!response.ok) { + const errorBody = await response.json().catch(() => ({})); + + // Try to parse as OAuth error response + const parseResult = OAuthErrorResponseSchema.safeParse(errorBody); + if (parseResult.success) { + const { error, error_description } = parseResult.data; + throw new Error(`JWT grant exchange failed: ${error}${error_description ? ` - ${error_description}` : ''}`); + } + + throw new Error(`JWT grant exchange failed with status ${response.status}: ${JSON.stringify(errorBody)}`); + } + + const responseBody = await response.json(); + + // Validate response using core schema + const parseResult = OAuthTokensSchema.safeParse(responseBody); + if (!parseResult.success) { + throw new Error(`Invalid token response: ${parseResult.error.message}`); + } + + return parseResult.data; +} diff --git a/packages/client/src/client/middleware.examples.ts b/packages/client/src/client/middleware.examples.ts new file mode 100644 index 0000000..9ccea3a --- /dev/null +++ b/packages/client/src/client/middleware.examples.ts @@ -0,0 +1,89 @@ +/** + * Type-checked examples for `middleware.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Middleware } from './middleware.js'; +import { applyMiddlewares, createMiddleware } from './middleware.js'; + +// Stubs for hypothetical application middleware +declare function withOAuth(provider: unknown, url: string): Middleware; +declare function withLogging(opts: { statusLevel: number }): Middleware; + +// Stubs for hypothetical application cache +declare function getFromCache(key: string): Promise; +declare function saveToCache(key: string, value: string): Promise; + +/** + * Example: Creating a middleware pipeline for OAuth and logging. + */ +async function applyMiddlewares_basicUsage(oauthProvider: unknown) { + //#region applyMiddlewares_basicUsage + // Create a middleware pipeline that handles both OAuth and logging + const enhancedFetch = applyMiddlewares(withOAuth(oauthProvider, 'https://api.example.com'), withLogging({ statusLevel: 400 }))(fetch); + + // Use the enhanced fetch - it will handle auth and log errors + const response = await enhancedFetch('https://api.example.com/data'); + //#endregion applyMiddlewares_basicUsage + return response; +} + +/** + * Example: Creating various custom middlewares with createMiddleware. + */ +function createMiddleware_examples() { + //#region createMiddleware_examples + // Create custom authentication middleware + const customAuthMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Auth', 'my-token'); + + const response = await next(input, { ...init, headers }); + + if (response.status === 401) { + console.log('Authentication failed'); + } + + return response; + }); + + // Create conditional middleware + const conditionalMiddleware = createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + + // Only add headers for API routes + if (url.includes('/api/')) { + const headers = new Headers(init?.headers); + headers.set('X-API-Version', 'v2'); + return next(input, { ...init, headers }); + } + + // Pass through for non-API routes + return next(input, init); + }); + + // Create caching middleware + const cacheMiddleware = createMiddleware(async (next, input, init) => { + const cacheKey = typeof input === 'string' ? input : input.toString(); + + // Check cache first + const cached = await getFromCache(cacheKey); + if (cached) { + return new Response(cached, { status: 200 }); + } + + // Make request and cache result + const response = await next(input, init); + if (response.ok) { + await saveToCache(cacheKey, await response.clone().text()); + } + + return response; + }); + //#endregion createMiddleware_examples + return { customAuthMiddleware, conditionalMiddleware, cacheMiddleware }; +} diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts new file mode 100644 index 0000000..7494144 --- /dev/null +++ b/packages/client/src/client/middleware.ts @@ -0,0 +1,319 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; + +import type { OAuthClientProvider } from './auth.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; + +/** + * Middleware function that wraps and enhances fetch functionality. + * Takes a fetch handler and returns an enhanced fetch handler. + */ +export type Middleware = (next: FetchLike) => FetchLike; + +/** + * Creates a fetch wrapper that handles OAuth authentication automatically. + * + * This wrapper will: + * - Add `Authorization` headers with access tokens + * - Handle 401 responses by attempting re-authentication + * - Retry the original request after successful auth + * - Handle OAuth errors appropriately ({@linkcode index.OAuthErrorCode.InvalidClient | OAuthErrorCode.InvalidClient}, etc.) + * + * The `baseUrl` parameter is optional and defaults to using the domain from the request URL. + * However, you should explicitly provide `baseUrl` when: + * - Making requests to multiple subdomains (e.g., api.example.com, cdn.example.com) + * - Using API paths that differ from OAuth discovery paths (e.g., requesting /api/v1/data but OAuth is at /) + * - The OAuth server is on a different domain than your API requests + * - You want to ensure consistent OAuth behavior regardless of request URLs + * + * For MCP transports, set `baseUrl` to the same URL you pass to the transport constructor. + * + * Note: This wrapper is designed for general-purpose fetch operations. + * MCP transports (SSE and StreamableHTTP) already have built-in OAuth handling + * and should not need this wrapper. + * + * @param provider - OAuth client provider for authentication + * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @returns A fetch middleware function + */ +export const withOAuth = + (provider: OAuthClientProvider, baseUrl?: string | URL): Middleware => + next => { + return async (input, init) => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + + // Add authorization header if tokens are available + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + // Handle 401 responses by attempting re-authentication + if (response.status === 401) { + try { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + + // Use provided baseUrl or extract from request URL + const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); + + const result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + throw new UnauthorizedError('Authentication requires user authorization - redirect initiated'); + } + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(`Authentication failed with result: ${result}`); + } + + // Retry the request with fresh tokens + response = await makeRequest(); + } catch (error) { + if (error instanceof UnauthorizedError) { + throw error; + } + throw new UnauthorizedError(`Failed to re-authenticate: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // If we still have a 401 after re-auth attempt, throw an error + if (response.status === 401) { + const url = typeof input === 'string' ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; + +/** + * Logger function type for HTTP requests + */ +export type RequestLogger = (input: { + method: string; + url: string | URL; + status: number; + statusText: string; + duration: number; + requestHeaders?: Headers; + responseHeaders?: Headers; + error?: Error; +}) => void; + +/** + * Configuration options for the logging middleware + */ +export type LoggingOptions = { + /** + * Custom logger function, defaults to console logging + */ + logger?: RequestLogger; + + /** + * Whether to include request headers in logs + * @default false + */ + includeRequestHeaders?: boolean; + + /** + * Whether to include response headers in logs + * @default false + */ + includeResponseHeaders?: boolean; + + /** + * Status level filter - only log requests with status >= this value + * Set to `0` to log all requests, `400` to log only errors + * @default 0 + */ + statusLevel?: number; +}; + +/** + * Creates a fetch middleware that logs HTTP requests and responses. + * + * When called without arguments `withLogging()`, it uses the default logger that: + * - Logs successful requests (2xx) to `console.log` + * - Logs error responses (4xx/5xx) and network errors to `console.error` + * - Logs all requests regardless of status (`statusLevel: 0`) + * - Does not include request or response headers in logs + * - Measures and displays request duration in milliseconds + * + * Important: the default logger uses both `console.log` and `console.error` so it should not be used with + * `stdio` transports and applications. + * + * @param options - Logging configuration options + * @returns A fetch middleware function + */ +export const withLogging = (options: LoggingOptions = {}): Middleware => { + const { logger, includeRequestHeaders = false, includeResponseHeaders = false, statusLevel = 0 } = options; + + const defaultLogger: RequestLogger = input => { + const { method, url, status, statusText, duration, requestHeaders, responseHeaders, error } = input; + + let message = error + ? `HTTP ${method} ${url} failed: ${error.message} (${duration}ms)` + : `HTTP ${method} ${url} ${status} ${statusText} (${duration}ms)`; + + // Add headers to message if requested + if (includeRequestHeaders && requestHeaders) { + const reqHeaders = [...requestHeaders.entries()].map(([key, value]) => `${key}: ${value}`).join(', '); + message += `\n Request Headers: {${reqHeaders}}`; + } + + if (includeResponseHeaders && responseHeaders) { + const resHeaders = [...responseHeaders.entries()].map(([key, value]) => `${key}: ${value}`).join(', '); + message += `\n Response Headers: {${resHeaders}}`; + } + + if (error || status >= 400) { + // eslint-disable-next-line no-console + console.error(message); + } else { + // eslint-disable-next-line no-console + console.log(message); + } + }; + + const logFn = logger || defaultLogger; + + return next => async (input, init) => { + const startTime = performance.now(); + const method = init?.method || 'GET'; + const url = typeof input === 'string' ? input : input.toString(); + const requestHeaders = includeRequestHeaders ? new Headers(init?.headers) : undefined; + + try { + const response = await next(input, init); + const duration = performance.now() - startTime; + + // Only log if status meets the log level threshold + if (response.status >= statusLevel) { + logFn({ + method, + url, + status: response.status, + statusText: response.statusText, + duration, + requestHeaders, + responseHeaders: includeResponseHeaders ? response.headers : undefined + }); + } + + return response; + } catch (error) { + const duration = performance.now() - startTime; + + // Always log errors regardless of log level + logFn({ + method, + url, + status: 0, + statusText: 'Network Error', + duration, + requestHeaders, + error: error as Error + }); + + throw error; + } + }; +}; + +/** + * Composes multiple fetch middleware functions into a single middleware pipeline. + * Middleware are applied in the order they appear, creating a chain of handlers. + * + * @example + * ```ts source="./middleware.examples.ts#applyMiddlewares_basicUsage" + * // Create a middleware pipeline that handles both OAuth and logging + * const enhancedFetch = applyMiddlewares(withOAuth(oauthProvider, 'https://api.example.com'), withLogging({ statusLevel: 400 }))(fetch); + * + * // Use the enhanced fetch - it will handle auth and log errors + * const response = await enhancedFetch('https://api.example.com/data'); + * ``` + * + * @param middleware - Array of fetch middleware to compose into a pipeline + * @returns A single composed middleware function + */ +export const applyMiddlewares = (...middleware: Middleware[]): Middleware => { + return next => { + let handler = next; + for (const mw of middleware) { + handler = mw(handler); + } + return handler; + }; +}; + +/** + * Helper function to create custom fetch middleware with cleaner syntax. + * Provides the next handler and request details as separate parameters for easier access. + * + * @example + * ```ts source="./middleware.examples.ts#createMiddleware_examples" + * // Create custom authentication middleware + * const customAuthMiddleware = createMiddleware(async (next, input, init) => { + * const headers = new Headers(init?.headers); + * headers.set('X-Custom-Auth', 'my-token'); + * + * const response = await next(input, { ...init, headers }); + * + * if (response.status === 401) { + * console.log('Authentication failed'); + * } + * + * return response; + * }); + * + * // Create conditional middleware + * const conditionalMiddleware = createMiddleware(async (next, input, init) => { + * const url = typeof input === 'string' ? input : input.toString(); + * + * // Only add headers for API routes + * if (url.includes('/api/')) { + * const headers = new Headers(init?.headers); + * headers.set('X-API-Version', 'v2'); + * return next(input, { ...init, headers }); + * } + * + * // Pass through for non-API routes + * return next(input, init); + * }); + * + * // Create caching middleware + * const cacheMiddleware = createMiddleware(async (next, input, init) => { + * const cacheKey = typeof input === 'string' ? input : input.toString(); + * + * // Check cache first + * const cached = await getFromCache(cacheKey); + * if (cached) { + * return new Response(cached, { status: 200 }); + * } + * + * // Make request and cache result + * const response = await next(input, init); + * if (response.ok) { + * await saveToCache(cacheKey, await response.clone().text()); + * } + * + * return response; + * }); + * ``` + * + * @param handler - Function that receives the next handler and request parameters + * @returns A fetch middleware function + */ +export const createMiddleware = (handler: (next: FetchLike, input: string | URL, init?: RequestInit) => Promise): Middleware => { + return next => (input, init) => handler(next, input as string | URL, init); +}; diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts new file mode 100644 index 0000000..bf554ab --- /dev/null +++ b/packages/client/src/client/sse.ts @@ -0,0 +1,319 @@ +import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { + createFetchWithInit, + JSONRPCMessageSchema, + normalizeHeaders, + SdkError, + SdkErrorCode, + SdkHttpError +} from '@modelcontextprotocol/core'; +import type { ErrorEvent, EventSourceInit } from 'eventsource'; +import { EventSource } from 'eventsource'; + +import type { AuthProvider, OAuthClientProvider } from './auth.js'; +import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; + +export class SseError extends Error { + constructor( + public readonly code: number | undefined, + message: string | undefined, + public readonly event: ErrorEvent + ) { + super(`SSE error: ${message}`); + } +} + +/** + * Configuration options for the {@linkcode SSEClientTransport}. + */ +export type SSEClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * {@linkcode AuthProvider.token | token()} is called before every request to obtain the + * bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()} + * is called (if provided) to refresh credentials, then the request is retried once. If + * the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError} + * is thrown. + * + * For simple bearer tokens: `{ token: async () => myApiKey }`. + * + * For OAuth flows, pass an {@linkcode index.OAuthClientProvider | OAuthClientProvider} implementation. + * Interactive flows: after {@linkcode UnauthorizedError}, redirect the user, then call + * {@linkcode SSEClientTransport.finishAuth | finishAuth} with the authorization code before reconnecting. + */ + authProvider?: AuthProvider | OAuthClientProvider; + + /** + * Customizes the initial SSE request to the server (the request that begins the stream). + * + * NOTE: Setting this property will prevent an `Authorization` header from + * being automatically attached to the SSE request, if an {@linkcode SSEClientTransportOptions.authProvider | authProvider} is + * also given. This can be worked around by setting the `Authorization` header + * manually. + */ + eventSourceInit?: EventSourceInit; + + /** + * Customizes recurring `POST` requests to the server. + */ + requestInit?: RequestInit; + + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; +}; + +/** + * Client transport for SSE: this will connect to a server using Server-Sent Events for receiving + * messages and make separate `POST` requests for sending messages. + * @deprecated SSEClientTransport is deprecated. Prefer to use {@linkcode index.StreamableHTTPClientTransport | StreamableHTTPClientTransport} where possible instead. Note that because some servers are still using SSE, clients may need to support both transports during the migration period. + */ +export class SSEClientTransport implements Transport { + private _eventSource?: EventSource; + private _endpoint?: URL; + private _abortController?: AbortController; + private _url: URL; + private _resourceMetadataUrl?: URL; + private _scope?: string; + private _eventSourceInit?: EventSourceInit; + private _requestInit?: RequestInit; + private _authProvider?: AuthProvider; + private _oauthProvider?: OAuthClientProvider; + private _fetch?: FetchLike; + private _fetchWithInit: FetchLike; + private _protocolVersion?: string; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(url: URL, opts?: SSEClientTransportOptions) { + this._url = url; + this._resourceMetadataUrl = undefined; + this._scope = undefined; + this._eventSourceInit = opts?.eventSourceInit; + this._requestInit = opts?.requestInit; + if (isOAuthClientProvider(opts?.authProvider)) { + this._oauthProvider = opts.authProvider; + this._authProvider = adaptOAuthProvider(opts.authProvider); + } else { + this._authProvider = opts?.authProvider; + } + this._fetch = opts?.fetch; + this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + } + + private _last401Response?: Response; + + private async _commonHeaders(): Promise { + const headers: RequestInit['headers'] & Record = {}; + const token = await this._authProvider?.token(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + if (this._protocolVersion) { + headers['mcp-protocol-version'] = this._protocolVersion; + } + + const extraHeaders = normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders + }); + } + + private _startOrAuth(): Promise { + const fetchImpl = (this?._eventSourceInit?.fetch ?? this._fetch ?? fetch) as typeof fetch; + return new Promise((resolve, reject) => { + this._eventSource = new EventSource(this._url.href, { + ...this._eventSourceInit, + fetch: async (url, init) => { + const headers = await this._commonHeaders(); + headers.set('Accept', 'text/event-stream'); + const response = await fetchImpl(url, { + ...init, + headers + }); + + if (response.status === 401) { + this._last401Response = response; + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + } + } + + return response; + } + }); + this._abortController = new AbortController(); + + this._eventSource.onerror = event => { + if (event.code === 401 && this._authProvider) { + if (this._authProvider.onUnauthorized && this._last401Response) { + const response = this._last401Response; + this._last401Response = undefined; + this._eventSource?.close(); + this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( + // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. + () => this._startOrAuth().then(resolve, reject), + // onUnauthorized failed → not yet reported. + error => { + this.onerror?.(error); + reject(error); + } + ); + return; + } + const error = new UnauthorizedError(); + reject(error); + this.onerror?.(error); + return; + } + + const error = new SseError(event.code, event.message, event); + reject(error); + this.onerror?.(error); + }; + + this._eventSource.onopen = () => { + // The connection is open, but we need to wait for the endpoint to be received. + }; + + this._eventSource.addEventListener('endpoint', (event: Event) => { + const messageEvent = event as MessageEvent; + + try { + this._endpoint = new URL(messageEvent.data, this._url); + if (this._endpoint.origin !== this._url.origin) { + throw new Error(`Endpoint origin does not match connection origin: ${this._endpoint.origin}`); + } + } catch (error) { + reject(error); + this.onerror?.(error as Error); + + void this.close(); + return; + } + + resolve(); + }); + + this._eventSource.onmessage = (event: Event) => { + const messageEvent = event as MessageEvent; + let message: JSONRPCMessage; + try { + message = JSONRPCMessageSchema.parse(JSON.parse(messageEvent.data)); + } catch (error) { + this.onerror?.(error as Error); + return; + } + + this.onmessage?.(message); + }; + }); + } + + async start() { + if (this._eventSource) { + throw new Error('SSEClientTransport already started! If using Client class, note that connect() calls start() automatically.'); + } + + return await this._startOrAuth(); + } + + /** + * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + */ + async finishAuth(authorizationCode: string): Promise { + if (!this._oauthProvider) { + throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); + } + + const result = await auth(this._oauthProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError('Failed to authorize'); + } + } + + async close(): Promise { + this._abortController?.abort(); + this._eventSource?.close(); + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + return this._send(message, false); + } + + private async _send(message: JSONRPCMessage, isAuthRetry: boolean): Promise { + if (!this._endpoint) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + + try { + const headers = await this._commonHeaders(); + headers.set('content-type', 'application/json'); + const init = { + ...this._requestInit, + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this._abortController?.signal + }; + + const response = await (this._fetch ?? fetch)(this._endpoint, init); + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + } + + if (this._authProvider.onUnauthorized && !isAuthRetry) { + await this._authProvider.onUnauthorized({ + response, + serverUrl: this._url, + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); + // Purposely _not_ awaited, so we don't call onerror twice + return this._send(message, true); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) { + throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText + }); + } + throw new UnauthorizedError(); + } + + const text = await response.text?.().catch(() => null); + throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); + } + + // Release connection - POST responses don't have content we need + await response.text?.().catch(() => {}); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } +} diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts new file mode 100644 index 0000000..5dcb8ef --- /dev/null +++ b/packages/client/src/client/stdio.ts @@ -0,0 +1,260 @@ +import type { ChildProcess, IOType } from 'node:child_process'; +import process from 'node:process'; +import type { Stream } from 'node:stream'; +import { PassThrough } from 'node:stream'; + +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { ReadBuffer, SdkError, SdkErrorCode, serializeMessage } from '@modelcontextprotocol/core'; +import spawn from 'cross-spawn'; + +export type StdioServerParameters = { + /** + * The executable to run to start the server. + */ + command: string; + + /** + * Command line arguments to pass to the executable. + */ + args?: string[]; + + /** + * The environment to use when spawning the process. + * + * If not specified, the result of {@linkcode getDefaultEnvironment} will be used. + */ + env?: Record; + + /** + * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. + * + * The default is `"inherit"`, meaning messages to stderr will be printed to the parent process's stderr. + */ + stderr?: IOType | Stream | number; + + /** + * The working directory to use when spawning the process. + * + * If not specified, the current working directory will be inherited. + */ + cwd?: string; +}; + +/** + * Environment variables to inherit by default, if an environment is not explicitly given. + */ +export const DEFAULT_INHERITED_ENV_VARS = + process.platform === 'win32' + ? [ + 'APPDATA', + 'HOMEDRIVE', + 'HOMEPATH', + 'LOCALAPPDATA', + 'PATH', + 'PROCESSOR_ARCHITECTURE', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERNAME', + 'USERPROFILE', + 'PROGRAMFILES' + ] + : /* list inspired by the default env inheritance of sudo */ + ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; + +/** + * Returns a default environment object including only environment variables deemed safe to inherit. + */ +export function getDefaultEnvironment(): Record { + const env: Record = {}; + + for (const key of DEFAULT_INHERITED_ENV_VARS) { + const value = process.env[key]; + if (value === undefined) { + continue; + } + + if (value.startsWith('()')) { + // Skip functions, which are a security risk. + continue; + } + + env[key] = value; + } + + return env; +} + +/** + * Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. + * + * This transport is only available in Node.js environments. + */ +export class StdioClientTransport implements Transport { + private _process?: ChildProcess; + private _readBuffer: ReadBuffer = new ReadBuffer(); + private _serverParams: StdioServerParameters; + private _stderrStream: PassThrough | null = null; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(server: StdioServerParameters) { + this._serverParams = server; + if (server.stderr === 'pipe' || server.stderr === 'overlapped') { + this._stderrStream = new PassThrough(); + } + } + + /** + * Starts the server process and prepares to communicate with it. + */ + async start(): Promise { + if (this._process) { + throw new Error( + 'StdioClientTransport already started! If using Client class, note that connect() calls start() automatically.' + ); + } + + return new Promise((resolve, reject) => { + this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], { + // merge default env with server env because mcp server needs some env vars + env: { + ...getDefaultEnvironment(), + ...this._serverParams.env + }, + stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'], + shell: false, + windowsHide: process.platform === 'win32', + cwd: this._serverParams.cwd + }); + + this._process.on('error', error => { + reject(error); + this.onerror?.(error); + }); + + this._process.on('spawn', () => { + resolve(); + }); + + this._process.on('close', _code => { + this._process = undefined; + this.onclose?.(); + }); + + this._process.stdin?.on('error', error => { + this.onerror?.(error); + }); + + this._process.stdout?.on('data', chunk => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }); + + this._process.stdout?.on('error', error => { + this.onerror?.(error); + }); + + if (this._stderrStream && this._process.stderr) { + this._process.stderr.pipe(this._stderrStream); + } + }); + } + + /** + * The `stderr` stream of the child process, if {@linkcode StdioServerParameters.stderr} was set to `"pipe"` or `"overlapped"`. + * + * If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to + * attach listeners before the `start` method is invoked. This prevents loss of any early + * error output emitted by the child process. + */ + get stderr(): Stream | null { + if (this._stderrStream) { + return this._stderrStream; + } + + return this._process?.stderr ?? null; + } + + /** + * The child process pid spawned by this transport. + * + * This is only available after the transport has been started. + */ + get pid(): number | null { + return this._process?.pid ?? null; + } + + private processReadBuffer() { + while (true) { + try { + const message = this._readBuffer.readMessage(); + if (message === null) { + break; + } + + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + + async close(): Promise { + if (this._process) { + const processToClose = this._process; + this._process = undefined; + + const closePromise = new Promise(resolve => { + processToClose.once('close', () => { + resolve(); + }); + }); + + try { + processToClose.stdin?.end(); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]); + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGTERM'); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2000).unref())]); + } + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGKILL'); + } catch { + // ignore + } + } + } + + this._readBuffer.clear(); + } + + send(message: JSONRPCMessage): Promise { + return new Promise(resolve => { + if (!this._process?.stdin) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + + const json = serializeMessage(message); + if (this._process.stdin.write(json)) { + resolve(); + } else { + this._process.stdin.once('drain', resolve); + } + }); + } +} diff --git a/packages/client/src/client/streamableHttp.examples.ts b/packages/client/src/client/streamableHttp.examples.ts new file mode 100644 index 0000000..74023fa --- /dev/null +++ b/packages/client/src/client/streamableHttp.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `streamableHttp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +/* eslint-disable unicorn/consistent-function-scoping -- examples must live inside region blocks */ + +import type { ReconnectionScheduler } from './streamableHttp.js'; + +// Stub for a hypothetical platform-specific background scheduling API +declare const platformBackgroundTask: { + schedule(callback: () => void, delay: number): number; + cancel(id: number): void; +}; + +/** + * Example: Using a platform background-task API to schedule reconnections. + */ +function ReconnectionScheduler_basicUsage() { + //#region ReconnectionScheduler_basicUsage + const scheduler: ReconnectionScheduler = (reconnect, delay) => { + const id = platformBackgroundTask.schedule(reconnect, delay); + return () => platformBackgroundTask.cancel(id); + }; + //#endregion ReconnectionScheduler_basicUsage + return scheduler; +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts new file mode 100644 index 0000000..3b8ddaf --- /dev/null +++ b/packages/client/src/client/streamableHttp.ts @@ -0,0 +1,770 @@ +import type { ReadableWritablePair } from 'node:stream/web'; + +import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { + createFetchWithInit, + isInitializedNotification, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + JSONRPCMessageSchema, + normalizeHeaders, + SdkError, + SdkErrorCode, + SdkHttpError +} from '@modelcontextprotocol/core'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; + +import type { AuthProvider, OAuthClientProvider } from './auth.js'; +import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth.js'; + +// Default reconnection options for StreamableHTTP connections +const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30_000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2 +}; + +/** + * Options for starting or authenticating an SSE connection + */ +export interface StartSSEOptions { + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off. + */ + resumptionToken?: string; + + /** + * A callback that is invoked when the resumption token changes. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: (token: string) => void; + + /** + * Override Message ID to associate with the replay message + * so that the response can be associated with the new resumed request. + */ + replayMessageId?: string | number; +} + +/** + * Configuration options for reconnection behavior of the {@linkcode StreamableHTTPClientTransport}. + */ +export interface StreamableHTTPReconnectionOptions { + /** + * Maximum backoff time between reconnection attempts in milliseconds. + * Default is 30000 (30 seconds). + */ + maxReconnectionDelay: number; + + /** + * Initial backoff time between reconnection attempts in milliseconds. + * Default is 1000 (1 second). + */ + initialReconnectionDelay: number; + + /** + * The factor by which the reconnection delay increases after each attempt. + * Default is 1.5. + */ + reconnectionDelayGrowFactor: number; + + /** + * Maximum number of reconnection attempts before giving up. + * Default is 2. + */ + maxRetries: number; +} + +/** + * Custom scheduler for SSE stream reconnection attempts. + * + * Called instead of `setTimeout` when the transport needs to schedule a reconnection. + * Useful in environments where `setTimeout` is unsuitable (serverless functions that + * terminate before the timer fires, mobile apps that need platform background scheduling, + * desktop apps handling sleep/wake). + * + * @param reconnect - Call this to perform the reconnection attempt. + * @param delay - Suggested delay in milliseconds (from backoff calculation). + * @param attemptCount - Zero-indexed retry attempt number. + * @returns An optional cancel function. If returned, it will be called on + * {@linkcode StreamableHTTPClientTransport.close | transport.close()} to abort the + * pending reconnection. + * + * @example + * ```ts source="./streamableHttp.examples.ts#ReconnectionScheduler_basicUsage" + * const scheduler: ReconnectionScheduler = (reconnect, delay) => { + * const id = platformBackgroundTask.schedule(reconnect, delay); + * return () => platformBackgroundTask.cancel(id); + * }; + * ``` + */ +export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void; + +/** + * Configuration options for the {@linkcode StreamableHTTPClientTransport}. + */ +export type StreamableHTTPClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * {@linkcode AuthProvider.token | token()} is called before every request to obtain the + * bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()} + * is called (if provided) to refresh credentials, then the request is retried once. If + * the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError} + * is thrown. + * + * For simple bearer tokens: `{ token: async () => myApiKey }`. + * + * For OAuth flows, pass an {@linkcode index.OAuthClientProvider | OAuthClientProvider} implementation + * directly — the transport adapts it to `AuthProvider` internally. Interactive flows: after + * {@linkcode UnauthorizedError}, redirect the user, then call + * {@linkcode StreamableHTTPClientTransport.finishAuth | finishAuth} with the authorization code before + * reconnecting. + */ + authProvider?: AuthProvider | OAuthClientProvider; + + /** + * Customizes HTTP requests to the server. + */ + requestInit?: RequestInit; + + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; + + /** + * Options to configure the reconnection behavior. + */ + reconnectionOptions?: StreamableHTTPReconnectionOptions; + + /** + * Custom scheduler for reconnection attempts. If not provided, `setTimeout` is used. + * See {@linkcode ReconnectionScheduler}. + */ + reconnectionScheduler?: ReconnectionScheduler; + + /** + * Session ID for the connection. This is used to identify the session on the server. + * When not provided and connecting to a server that supports session IDs, the server will generate a new session ID. + */ + sessionId?: string; + + /** + * The MCP protocol version to include in the `mcp-protocol-version` header on all requests. + * When reconnecting with a preserved `sessionId`, set this to the version negotiated during the original + * handshake so the reconnected transport continues sending the required header. + */ + protocolVersion?: string; +}; + +/** + * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events + * for receiving messages. + */ +export class StreamableHTTPClientTransport implements Transport { + private _abortController?: AbortController; + private _url: URL; + private _resourceMetadataUrl?: URL; + private _scope?: string; + private _requestInit?: RequestInit; + private _authProvider?: AuthProvider; + private _oauthProvider?: OAuthClientProvider; + private _fetch?: FetchLike; + private _fetchWithInit: FetchLike; + private _sessionId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; + private _protocolVersion?: string; + private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. + private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field + private readonly _reconnectionScheduler?: ReconnectionScheduler; + private _cancelReconnection?: () => void; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) { + this._url = url; + this._resourceMetadataUrl = undefined; + this._scope = undefined; + this._requestInit = opts?.requestInit; + if (isOAuthClientProvider(opts?.authProvider)) { + this._oauthProvider = opts.authProvider; + this._authProvider = adaptOAuthProvider(opts.authProvider); + } else { + this._authProvider = opts?.authProvider; + } + this._fetch = opts?.fetch; + this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._sessionId = opts?.sessionId; + this._protocolVersion = opts?.protocolVersion; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + this._reconnectionScheduler = opts?.reconnectionScheduler; + } + + private async _commonHeaders(): Promise { + const headers: RequestInit['headers'] & Record = {}; + const token = await this._authProvider?.token(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (this._sessionId) { + headers['mcp-session-id'] = this._sessionId; + } + if (this._protocolVersion) { + headers['mcp-protocol-version'] = this._protocolVersion; + } + + const extraHeaders = normalizeHeaders(this._requestInit?.headers); + + return new Headers({ + ...headers, + ...extraHeaders + }); + } + + private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { + const { resumptionToken } = options; + + try { + // Try to open an initial SSE stream with GET to listen for server messages + // This is optional according to the spec - server may not support it + const headers = await this._commonHeaders(); + const userAccept = headers.get('accept'); + const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'text/event-stream']; + headers.set('accept', [...new Set(types)].join(', ')); + + // Include Last-Event-ID header for resumable streams if provided + if (resumptionToken) { + headers.set('last-event-id', resumptionToken); + } + + const response = await (this._fetch ?? fetch)(this._url, { + ...this._requestInit, + method: 'GET', + headers, + signal: this._abortController?.signal + }); + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + } + + if (this._authProvider.onUnauthorized && !isAuthRetry) { + await this._authProvider.onUnauthorized({ + response, + serverUrl: this._url, + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); + // Purposely _not_ awaited, so we don't call onerror twice + return this._startOrAuthSse(options, true); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) { + throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText + }); + } + throw new UnauthorizedError(); + } + + await response.text?.().catch(() => {}); + + // 405 indicates that the server does not offer an SSE stream at GET endpoint + // This is an expected case that should not trigger an error + if (response.status === 405) { + return; + } + + throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { + status: response.status, + statusText: response.statusText + }); + } + + this._handleSseStream(response.body, options, true); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + /** + * Calculates the next reconnection delay using a backoff algorithm + * + * @param attempt Current reconnection attempt count for the specific stream + * @returns Time to wait in milliseconds before next reconnection attempt + */ + private _getNextReconnectionDelay(attempt: number): number { + // Use server-provided retry value if available + if (this._serverRetryMs !== undefined) { + return this._serverRetryMs; + } + + // Fall back to exponential backoff + const initialDelay = this._reconnectionOptions.initialReconnectionDelay; + const growFactor = this._reconnectionOptions.reconnectionDelayGrowFactor; + const maxDelay = this._reconnectionOptions.maxReconnectionDelay; + + // Cap at maximum delay + return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); + } + + /** + * Schedule a reconnection attempt using server-provided retry interval or backoff + * + * @param lastEventId The ID of the last received event for resumability + * @param attemptCount Current reconnection attempt count for this specific stream + */ + private _scheduleReconnection(options: StartSSEOptions, attemptCount = 0): void { + // Use provided options or default options + const maxRetries = this._reconnectionOptions.maxRetries; + + // Check if we've exceeded maximum retry attempts + if (attemptCount >= maxRetries) { + this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + return; + } + + // Calculate next delay based on current attempt count + const delay = this._getNextReconnectionDelay(attemptCount); + + const reconnect = (): void => { + this._cancelReconnection = undefined; + if (this._abortController?.signal.aborted) return; + this._startOrAuthSse(options).catch(error => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); + try { + this._scheduleReconnection(options, attemptCount + 1); + } catch (scheduleError) { + this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError))); + } + }); + }; + + if (this._reconnectionScheduler) { + const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount); + this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined; + } else { + const handle = setTimeout(reconnect, delay); + this._cancelReconnection = () => clearTimeout(handle); + } + } + + private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { + if (!stream) { + return; + } + const { onresumptiontoken, replayMessageId } = options; + + let lastEventId: string | undefined; + // Track whether we've received a priming event (event with ID) + // Per spec, server SHOULD send a priming event with ID before closing + let hasPrimingEvent = false; + // Track whether we've received a response - if so, no need to reconnect + // Reconnection is for when server disconnects BEFORE sending response + let receivedResponse = false; + const processStream = async () => { + // this is the closest we can get to trying to catch network errors + // if something happens reader will throw + try { + // Create a pipeline: binary stream -> text decoder -> SSE parser + const reader = stream + .pipeThrough(new TextDecoderStream() as ReadableWritablePair) + .pipeThrough( + new EventSourceParserStream({ + onRetry: (retryMs: number) => { + // Capture server-provided retry value for reconnection timing + this._serverRetryMs = retryMs; + } + }) + ) + .getReader(); + + while (true) { + const { value: event, done } = await reader.read(); + if (done) { + break; + } + + // Update last event ID if provided + if (event.id) { + lastEventId = event.id; + // Mark that we've received a priming event - stream is now resumable + hasPrimingEvent = true; + onresumptiontoken?.(event.id); + } + + // Skip events with no data (priming events, keep-alives) + if (!event.data) { + continue; + } + + if (!event.event || event.event === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + // Handle both success AND error responses for completion detection and ID remapping + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { + message.id = replayMessageId; + } + } + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + + // Handle graceful server-side disconnect + // Server may close connection after sending event ID and retry field + // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) + // BUT don't reconnect if we already received a response - the request is complete + const canResume = isReconnectable || hasPrimingEvent; + const needsReconnect = canResume && !receivedResponse; + if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + this._scheduleReconnection( + { + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, + 0 + ); + } + } catch (error) { + // Handle stream errors - likely a network disconnect + this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); + + // Attempt to reconnect if the stream disconnects unexpectedly and we aren't closing + // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) + // BUT don't reconnect if we already received a response - the request is complete + const canResume = isReconnectable || hasPrimingEvent; + const needsReconnect = canResume && !receivedResponse; + if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + // Use the exponential backoff reconnection strategy + try { + this._scheduleReconnection( + { + resumptionToken: lastEventId, + onresumptiontoken, + replayMessageId + }, + 0 + ); + } catch (error) { + this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + } + } + } + }; + processStream(); + } + + async start() { + if (this._abortController) { + throw new Error( + 'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.' + ); + } + + this._abortController = new AbortController(); + } + + /** + * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + */ + async finishAuth(authorizationCode: string): Promise { + if (!this._oauthProvider) { + throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); + } + + const result = await auth(this._oauthProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError('Failed to authorize'); + } + } + + async close(): Promise { + try { + this._cancelReconnection?.(); + } finally { + this._cancelReconnection = undefined; + this._abortController?.abort(); + this.onclose?.(); + } + } + + async send( + message: JSONRPCMessage | JSONRPCMessage[], + options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } + ): Promise { + return this._send(message, options, false); + } + + private async _send( + message: JSONRPCMessage | JSONRPCMessage[], + options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined, + isAuthRetry: boolean + ): Promise { + try { + const { resumptionToken, onresumptiontoken } = options || {}; + + if (resumptionToken) { + // If we have a last event ID, we need to reconnect the SSE stream + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch( + error => this.onerror?.(error) + ); + return; + } + + const headers = await this._commonHeaders(); + headers.set('content-type', 'application/json'); + const userAccept = headers.get('accept'); + const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; + headers.set('accept', [...new Set(types)].join(', ')); + + const init = { + ...this._requestInit, + method: 'POST', + headers, + body: JSON.stringify(message), + signal: this._abortController?.signal + }; + + const response = await (this._fetch ?? fetch)(this._url, init); + + // Handle session ID received during initialization + const sessionId = response.headers.get('mcp-session-id'); + if (sessionId) { + this._sessionId = sessionId; + } + + if (!response.ok) { + if (response.status === 401 && this._authProvider) { + // Store WWW-Authenticate params for interactive finishAuth() path + if (response.headers.has('www-authenticate')) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + } + + if (this._authProvider.onUnauthorized && !isAuthRetry) { + await this._authProvider.onUnauthorized({ + response, + serverUrl: this._url, + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); + // Purposely _not_ awaited, so we don't call onerror twice + return this._send(message, options, true); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) { + throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: response.statusText + }); + } + throw new UnauthorizedError(); + } + + const text = await response.text?.().catch(() => null); + + if (response.status === 403 && this._oauthProvider) { + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + + if (error === 'insufficient_scope') { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + + // Check if we've already tried upscoping with this header to prevent infinite loops. + if (this._lastUpscopingHeader === wwwAuthHeader) { + throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', { + status: 403, + statusText: response.statusText, + text + }); + } + + if (scope) { + this._scope = scope; + } + + if (resourceMetadataUrl) { + this._resourceMetadataUrl = resourceMetadataUrl; + } + + // Mark that upscoping was tried. + this._lastUpscopingHeader = wwwAuthHeader ?? undefined; + const result = await auth(this._oauthProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return this._send(message, options, isAuthRetry); + } + } + + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { + status: response.status, + statusText: response.statusText, + text + }); + } + + this._lastUpscopingHeader = undefined; + + // If the response is 202 Accepted, there's no body to process + if (response.status === 202) { + await response.text?.().catch(() => {}); + // if the accepted notification is initialized, we start the SSE stream + // if it's supported by the server + if (isInitializedNotification(message)) { + // Start without a lastEventId since this is a fresh connection + this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error)); + } + return; + } + + // Get original message(s) for detecting request IDs + const messages = Array.isArray(message) ? message : [message]; + + const hasRequests = messages.some(msg => 'method' in msg && 'id' in msg && msg.id !== undefined); + + // Check the response type + const contentType = response.headers.get('content-type'); + + if (hasRequests) { + if (contentType?.includes('text/event-stream')) { + // Handle SSE stream responses for requests + // We use the same handler as standalone streams, which now supports + // reconnection with the last event ID + this._handleSseStream(response.body, { onresumptiontoken }, false); + } else if (contentType?.includes('application/json')) { + // For non-streaming servers, we might get direct JSON responses + const data = await response.json(); + const responseMessages = Array.isArray(data) + ? data.map(msg => JSONRPCMessageSchema.parse(msg)) + : [JSONRPCMessageSchema.parse(data)]; + + for (const msg of responseMessages) { + this.onmessage?.(msg); + } + } else { + await response.text?.().catch(() => {}); + throw new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, `Unexpected content type: ${contentType}`, { + contentType + }); + } + } else { + // No requests in message but got 200 OK - still need to release connection + await response.text?.().catch(() => {}); + } + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + get sessionId(): string | undefined { + return this._sessionId; + } + + /** + * Terminates the current session by sending a `DELETE` request to the server. + * + * Clients that no longer need a particular session + * (e.g., because the user is leaving the client application) SHOULD send an + * HTTP `DELETE` to the MCP endpoint with the `Mcp-Session-Id` header to explicitly + * terminate the session. + * + * The server MAY respond with HTTP `405 Method Not Allowed`, indicating that + * the server does not allow clients to terminate sessions. + */ + async terminateSession(): Promise { + if (!this._sessionId) { + return; // No session to terminate + } + + try { + const headers = await this._commonHeaders(); + + const init = { + ...this._requestInit, + method: 'DELETE', + headers, + signal: this._abortController?.signal + }; + + const response = await (this._fetch ?? fetch)(this._url, init); + await response.text?.().catch(() => {}); + + // We specifically handle 405 as a valid response according to the spec, + // meaning the server does not support explicit session termination + if (!response.ok && response.status !== 405) { + throw new SdkHttpError( + SdkErrorCode.ClientHttpFailedToTerminateSession, + `Failed to terminate session: ${response.statusText}`, + { + status: response.status, + statusText: response.statusText + } + ); + } + + this._sessionId = undefined; + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + get protocolVersion(): string | undefined { + return this._protocolVersion; + } + + /** + * Resume an SSE stream from a previous event ID. + * Opens a `GET` SSE connection with `Last-Event-ID` header to replay missed events. + * + * @param lastEventId The event ID to resume from + * @param options Optional callback to receive new resumption tokens + */ + async resumeStream(lastEventId: string, options?: { onresumptiontoken?: (token: string) => void }): Promise { + await this._startOrAuthSse({ + resumptionToken: lastEventId, + onresumptiontoken: options?.onresumptiontoken + }); + } +} diff --git a/packages/client/src/experimental/index.ts b/packages/client/src/experimental/index.ts new file mode 100644 index 0000000..926369f --- /dev/null +++ b/packages/client/src/experimental/index.ts @@ -0,0 +1,13 @@ +/** + * Experimental MCP SDK features. + * WARNING: These APIs are experimental and may change without notice. + * + * Import experimental features from this module: + * ```typescript + * import { TaskStore, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental'; + * ``` + * + * @experimental + */ + +export * from './tasks/client.js'; diff --git a/packages/client/src/experimental/tasks/client.examples.ts b/packages/client/src/experimental/tasks/client.examples.ts new file mode 100644 index 0000000..5652062 --- /dev/null +++ b/packages/client/src/experimental/tasks/client.examples.ts @@ -0,0 +1,70 @@ +/** + * Type-checked examples for `client.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { RequestOptions } from '@modelcontextprotocol/core'; + +import type { Client } from '../../client/client.js'; + +/** + * Example: Using callToolStream to execute a tool with task lifecycle events. + */ +async function ExperimentalClientTasks_callToolStream(client: Client) { + //#region ExperimentalClientTasks_callToolStream + const stream = client.experimental.tasks.callToolStream({ name: 'myTool', arguments: {} }); + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log('Tool execution started:', message.task.taskId); + break; + } + case 'taskStatus': { + console.log('Tool status:', message.task.status); + break; + } + case 'result': { + console.log('Tool result:', message.result); + break; + } + case 'error': { + console.error('Tool error:', message.error); + break; + } + } + } + //#endregion ExperimentalClientTasks_callToolStream +} + +/** + * Example: Using requestStream to consume task lifecycle events for any request type. + */ +async function ExperimentalClientTasks_requestStream(client: Client, options: RequestOptions) { + //#region ExperimentalClientTasks_requestStream + const stream = client.experimental.tasks.requestStream({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, options); + for await (const message of stream) { + switch (message.type) { + case 'taskCreated': { + console.log('Task created:', message.task.taskId); + break; + } + case 'taskStatus': { + console.log('Task status:', message.task.status); + break; + } + case 'result': { + console.log('Final result:', message.result); + break; + } + case 'error': { + console.error('Error:', message.error); + break; + } + } + } + //#endregion ExperimentalClientTasks_requestStream +} diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts new file mode 100644 index 0000000..75ba873 --- /dev/null +++ b/packages/client/src/experimental/tasks/client.ts @@ -0,0 +1,277 @@ +/** + * Experimental client task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { + AnyObjectSchema, + CallToolRequest, + CallToolResult, + CancelTaskResult, + CreateTaskResult, + GetTaskPayloadResult, + GetTaskResult, + ListTasksResult, + Request, + RequestMethod, + RequestOptions, + ResponseMessage, + ResultTypeMap +} from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + getResultSchema, + GetTaskPayloadResultSchema, + ProtocolError, + ProtocolErrorCode +} from '@modelcontextprotocol/core'; + +import type { Client } from '../../client/client.js'; + +/** + * Internal interface for accessing {@linkcode Client}'s private methods. + * @internal + */ +interface ClientInternal { + isToolTask(toolName: string): boolean; + getToolOutputValidator(toolName: string): ((data: unknown) => { valid: boolean; errorMessage?: string }) | undefined; +} + +/** + * Experimental task features for MCP clients. + * + * Access via `client.experimental.tasks`: + * ```typescript + * const stream = client.experimental.tasks.callToolStream({ name: 'tool', arguments: {} }); + * const task = await client.experimental.tasks.getTask(taskId); + * ``` + * + * @experimental + */ +export class ExperimentalClientTasks { + constructor(private readonly _client: Client) {} + + private get _module() { + return this._client.taskManager; + } + + /** + * Calls a tool and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a `'result'` or `'error'` message. + * + * This method provides streaming access to tool execution, allowing you to + * observe intermediate task status updates for long-running tool calls. + * Automatically validates structured output if the tool has an `outputSchema`. + * + * @example + * ```ts source="./client.examples.ts#ExperimentalClientTasks_callToolStream" + * const stream = client.experimental.tasks.callToolStream({ name: 'myTool', arguments: {} }); + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': { + * console.log('Tool execution started:', message.task.taskId); + * break; + * } + * case 'taskStatus': { + * console.log('Tool status:', message.task.status); + * break; + * } + * case 'result': { + * console.log('Tool result:', message.result); + * break; + * } + * case 'error': { + * console.error('Tool error:', message.error); + * break; + * } + * } + * } + * ``` + * + * @param params - Tool call parameters (name and arguments) + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects + * + * @experimental + */ + async *callToolStream( + params: CallToolRequest['params'], + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access Client's internal methods + const clientInternal = this._client as unknown as ClientInternal; + + // Add task creation parameters if server supports it and not explicitly provided + const optionsWithTask = { + ...options, + // We check if the tool is known to be a task during auto-configuration, but assume + // the caller knows what they're doing if they pass this explicitly + task: options?.task ?? (clientInternal.isToolTask(params.name) ? {} : undefined) + }; + + const stream = this._module.requestStream({ method: 'tools/call', params }, CallToolResultSchema, optionsWithTask); + + // Get the validator for this tool (if it has an output schema) + const validator = clientInternal.getToolOutputValidator(params.name); + + // Iterate through the stream and validate the final result if needed + for await (const message of stream) { + // If this is a result message and the tool has an output schema, validate it + // Only validate CallToolResult (has 'content'), not CreateTaskResult (has 'task') + if (message.type === 'result' && validator && 'content' in message.result) { + const result = message.result as CallToolResult; + + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + yield { + type: 'error', + error: new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Tool ${params.name} has an output schema but did not return structured content` + ) + }; + return; + } + + // Only validate structured content if present (not when there's an error) + if (result.structuredContent) { + try { + // Validate the structured content against the schema + const validationResult = validator(result.structuredContent); + + if (!validationResult.valid) { + yield { + type: 'error', + error: new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ) + }; + return; + } + } catch (error) { + if (error instanceof ProtocolError) { + yield { type: 'error', error }; + return; + } + yield { + type: 'error', + error: new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}` + ) + }; + return; + } + } + } + + // Yield the message (either validated result or any other message type) + yield message; + } + } + + /** + * Gets the current status of a task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * @returns The task status + * + * @experimental + */ + async getTask(taskId: string, options?: RequestOptions): Promise { + return this._module.getTask({ taskId }, options); + } + + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * @returns The task result. The payload structure matches the result type of the + * original request (e.g., a `tools/call` task returns a `CallToolResult`). + * + * @experimental + */ + async getTaskResult(taskId: string, options?: RequestOptions): Promise { + return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, options); + } + + /** + * Lists tasks with optional pagination. + * + * @param cursor - Optional pagination cursor + * @param options - Optional request options + * @returns List of tasks with optional next cursor + * + * @experimental + */ + async listTasks(cursor?: string, options?: RequestOptions): Promise { + return this._module.listTasks(cursor ? { cursor } : undefined, options); + } + + /** + * Cancels a running task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * + * @experimental + */ + async cancelTask(taskId: string, options?: RequestOptions): Promise { + return this._module.cancelTask({ taskId }, options); + } + + /** + * Sends a request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a `'result'` or `'error'` message. + * + * This method provides streaming access to request processing, allowing you to + * observe intermediate task status updates for task-augmented requests. + * + * @example + * ```ts source="./client.examples.ts#ExperimentalClientTasks_requestStream" + * const stream = client.experimental.tasks.requestStream({ method: 'tools/call', params: { name: 'my-tool', arguments: {} } }, options); + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': { + * console.log('Task created:', message.task.taskId); + * break; + * } + * case 'taskStatus': { + * console.log('Task status:', message.task.status); + * break; + * } + * case 'result': { + * console.log('Final result:', message.result); + * break; + * } + * case 'error': { + * console.error('Error:', message.error); + * break; + * } + * } + * } + * ``` + * + * @param request - The request to send + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects + * + * @experimental + */ + requestStream( + request: { method: M; params?: Record }, + options?: RequestOptions + ): AsyncGenerator, void, void> { + const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema; + return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator< + ResponseMessage, + void, + void + >; + } +} diff --git a/packages/client/src/fromJsonSchema.ts b/packages/client/src/fromJsonSchema.ts new file mode 100644 index 0000000..575db2a --- /dev/null +++ b/packages/client/src/fromJsonSchema.ts @@ -0,0 +1,9 @@ +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/client/_shims'; +import type { JsonSchemaType, jsonSchemaValidator, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { fromJsonSchema as coreFromJsonSchema } from '@modelcontextprotocol/core'; + +let _defaultValidator: jsonSchemaValidator | undefined; + +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON { + return coreFromJsonSchema(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator())); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..06ca114 --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,81 @@ +// Public API for @modelcontextprotocol/client. +// +// This file defines the complete public surface. It consists of: +// - Package-specific exports: listed explicitly below (named imports) +// - Protocol-level types: re-exported from @modelcontextprotocol/core/public +// +// Any new export added here becomes public API. Use named exports, not wildcards. + +export type { + AddClientAuthentication, + AuthProvider, + AuthResult, + ClientAuthMethod, + OAuthClientProvider, + OAuthDiscoveryState, + OAuthServerInfo +} from './client/auth.js'; +export { + auth, + buildDiscoveryUrls, + discoverAuthorizationServerMetadata, + discoverOAuthMetadata, + discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, + exchangeAuthorization, + extractResourceMetadataUrl, + extractWWWAuthenticateParams, + fetchToken, + isHttpsUrl, + parseErrorResponse, + prepareAuthorizationCodeRequest, + refreshAuthorization, + registerClient, + selectClientAuthMethod, + selectResourceURL, + startAuthorization, + UnauthorizedError, + validateClientMetadataUrl +} from './client/auth.js'; +export type { + AssertionCallback, + ClientCredentialsProviderOptions, + CrossAppAccessContext, + CrossAppAccessProviderOptions, + PrivateKeyJwtProviderOptions, + StaticPrivateKeyJwtProviderOptions +} from './client/authExtensions.js'; +export { + ClientCredentialsProvider, + createPrivateKeyJwtAuth, + CrossAppAccessProvider, + PrivateKeyJwtProvider, + StaticPrivateKeyJwtProvider +} from './client/authExtensions.js'; +export type { ClientOptions } from './client/client.js'; +export { Client } from './client/client.js'; +export { getSupportedElicitationModes } from './client/client.js'; +export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; +export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess.js'; +export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware.js'; +export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware.js'; +export type { SSEClientTransportOptions } from './client/sse.js'; +export { SSEClientTransport, SseError } from './client/sse.js'; +// StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from +// the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn). +export type { + ReconnectionScheduler, + StartSSEOptions, + StreamableHTTPClientTransportOptions, + StreamableHTTPReconnectionOptions +} from './client/streamableHttp.js'; +export { StreamableHTTPClientTransport } from './client/streamableHttp.js'; + +// experimental exports +export { ExperimentalClientTasks } from './experimental/tasks/client.js'; + +// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) +export { fromJsonSchema } from './fromJsonSchema.js'; + +// re-export curated public API from core +export * from '@modelcontextprotocol/core/public'; diff --git a/packages/client/src/shimsBrowser.ts b/packages/client/src/shimsBrowser.ts new file mode 100644 index 0000000..de126d5 --- /dev/null +++ b/packages/client/src/shimsBrowser.ts @@ -0,0 +1,13 @@ +/** + * Browser runtime shims for client package + * + * This file is selected via package.json export conditions when running in a browser. + */ +export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. Only true in browser contexts + * (including Web Workers / Service Workers). In Node.js and Cloudflare Workers, a + * `TypeError` from `fetch` is always a real network/configuration error. + */ +export const CORS_IS_POSSIBLE = true; diff --git a/packages/client/src/shimsNode.ts b/packages/client/src/shimsNode.ts new file mode 100644 index 0000000..00b80ab --- /dev/null +++ b/packages/client/src/shimsNode.ts @@ -0,0 +1,13 @@ +/** + * Node.js runtime shims for client package + * + * This file is selected via package.json export conditions when running in Node.js. + */ +export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept — + * in Node.js, a `TypeError` from `fetch` is always a real network/configuration error + * (DNS resolution, connection refused, invalid URL), never a CORS error. + */ +export const CORS_IS_POSSIBLE = false; diff --git a/packages/client/src/shimsWorkerd.ts b/packages/client/src/shimsWorkerd.ts new file mode 100644 index 0000000..9e6660b --- /dev/null +++ b/packages/client/src/shimsWorkerd.ts @@ -0,0 +1,13 @@ +/** + * Cloudflare Workers runtime shims for client package + * + * This file is selected via package.json export conditions when running in workerd. + */ +export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; + +/** + * Whether `fetch()` may throw `TypeError` due to CORS. CORS is a browser-only concept — + * in Cloudflare Workers, a `TypeError` from `fetch` is always a real network/configuration + * error, never a CORS error. + */ +export const CORS_IS_POSSIBLE = false; diff --git a/packages/client/src/stdio.ts b/packages/client/src/stdio.ts new file mode 100644 index 0000000..a6ecd16 --- /dev/null +++ b/packages/client/src/stdio.ts @@ -0,0 +1,8 @@ +// Subpath entry for the stdio client transport. +// +// Exported separately from the root entry so that bundling `@modelcontextprotocol/client` for browser or +// Cloudflare Workers targets does not pull in `node:child_process`, `node:stream`, or `cross-spawn`. Import +// from `@modelcontextprotocol/client/stdio` only in process-spawning runtimes (Node.js, Bun, Deno). + +export type { StdioServerParameters } from './client/stdio.js'; +export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from './client/stdio.js'; diff --git a/packages/client/src/validators/cfWorker.ts b/packages/client/src/validators/cfWorker.ts new file mode 100644 index 0000000..8d66770 --- /dev/null +++ b/packages/client/src/validators/cfWorker.ts @@ -0,0 +1,10 @@ +/** + * Cloudflare Workers JSON Schema validator, available as a sub-path export. + * + * @example + * ```ts + * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/client/validators/cf-worker'; + * ``` + */ +export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core/validators/cfWorker'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts new file mode 100644 index 0000000..04d7f4a --- /dev/null +++ b/packages/client/test/client/auth.test.ts @@ -0,0 +1,4133 @@ +import type { AuthorizationServerMetadata, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core'; +import type { Mock } from 'vitest'; +import { expect, vi } from 'vitest'; + +import type { OAuthClientProvider } from '../../src/client/auth.js'; +import { + auth, + buildDiscoveryUrls, + determineScope, + discoverAuthorizationServerMetadata, + discoverOAuthMetadata, + discoverOAuthProtectedResourceMetadata, + discoverOAuthServerInfo, + exchangeAuthorization, + extractWWWAuthenticateParams, + isHttpsUrl, + refreshAuthorization, + registerClient, + selectClientAuthMethod, + startAuthorization, + validateClientMetadataUrl +} from '../../src/client/auth.js'; +import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions.js'; + +// Mock pkce-challenge +vi.mock('pkce-challenge', () => ({ + default: () => ({ + code_verifier: 'test_verifier', + code_challenge: 'test_challenge' + }) +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch; + +/** + * fetchWithCorsRetry gates its CORS-swallowing heuristic on the `CORS_IS_POSSIBLE` shim constant. + * Tests run under the Node shim (`false`), so a fetch TypeError is treated as a real network error + * and thrown instead of swallowed. Tests that specifically exercise the browser CORS retry path + * call `withBrowserLikeEnvironment()` to flip the mocked constant to `true`. The `afterEach` hook + * resets it so a failed assertion can't leak the override into later tests. + */ +let mockedCorsIsPossible = false; +vi.mock('@modelcontextprotocol/client/_shims', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + get CORS_IS_POSSIBLE() { + return mockedCorsIsPossible; + } + }; +}); +function withBrowserLikeEnvironment(): void { + mockedCorsIsPossible = true; +} + +describe('OAuth Authorization', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + afterEach(() => { + mockedCorsIsPossible = false; + }); + + describe('extractWWWAuthenticateParams', () => { + it('returns resource metadata url when present', async () => { + const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource'; + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ resourceMetadataUrl: new URL(resourceUrl) }); + }); + + it('returns scope when present', async () => { + const scope = 'read'; + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope }); + }); + + it('returns empty object if not bearer', async () => { + const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource'; + const scope = 'read'; + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null + ) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({}); + }); + + it('returns empty object if resource_metadata and scope not present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({}); + }); + + it('returns undefined resourceMetadataUrl on invalid url', async () => { + const resourceUrl = 'invalid-url'; + const scope = 'read'; + const mockResponse = { + headers: { + get: vi.fn(name => + name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null + ) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope }); + }); + + it('returns error when present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' }); + }); + }); + + describe('discoverOAuthProtectedResourceMetadata', () => { + const validMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + it('returns metadata when discovery succeeds', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + }); + + it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => { + withBrowserLikeEnvironment(); + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + return callCount === 1 + ? Promise.reject(new TypeError('Network error')) + : Promise.resolve({ + ok: true, + status: 200, + json: async () => validMetadata + }); + }); + + // Should succeed with the second call + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + expect(metadata).toEqual(validMetadata); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify first call had MCP header + expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); + }); + + it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => { + withBrowserLikeEnvironment(); + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + return callCount === 1 ? Promise.reject(new TypeError('First failure')) : Promise.reject(new Error('Second failure')); + }); + + // Should fail with the second error + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('Second failure'); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws TypeError immediately in non-browser environments without retrying', async () => { + // In Node.js/Workers, CORS doesn't exist — a TypeError from fetch is a real + // network/config error (DNS failure, connection refused, invalid URL) and + // should propagate rather than being silently swallowed. + mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com'))); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(TypeError); + + // Only one call — no CORS retry attempted + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('throws on 404 errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' + ); + }); + + it('throws on non-404 errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow('HTTP 500'); + }); + + it('validates metadata schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + scopes_supported: ['email', 'mcp'] + }) + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow(); + }); + + it('returns metadata when discovery succeeds with path', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); + }); + + it('preserves query parameters in path-aware discovery', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path?param=value'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path?param=value'); + }); + + it.each([400, 401, 403, 404, 410, 422, 429])( + 'falls back to root discovery when path-aware discovery returns %d', + async statusCode => { + // First call (path-aware) returns 4xx + mockFetch.mockResolvedValueOnce({ + ok: false, + status: statusCode + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name'); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]!; + expect(firstUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); + expect(firstOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]!; + expect(secondUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + expect(secondOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + } + ); + + it('throws error when both path-aware and root discovery return 404', async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it('throws on 500 status without fallback', async () => { + // First call (path-aware) returns 500 (overloaded server) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow('HTTP 500'); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + }); + + it('falls back to root on 502 status for path URL', async () => { + // First call (path-aware) returns 502 (reverse proxy routing error) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502 + }); + + // Root fallback also returns 502 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow('HTTP 502'); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); // Should attempt root fallback for 502 + }); + + it('does not fallback when the original URL is already at root path', async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/')).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + }); + + it('does not fallback when the original URL has no path', async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( + 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' + ); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + }); + + it('falls back when path-aware discovery encounters CORS error (browser)', async () => { + withBrowserLikeEnvironment(); + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/deep/path'); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]!; + expect(lastUrl.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + expect(lastOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('does not fallback when resourceMetadataUrl is provided', async () => { + // Call with explicit URL returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + await expect( + discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', { + resourceMetadataUrl: 'https://custom.example.com/metadata' + }) + ).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.'); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://custom.example.com/metadata'); + }); + + it('supports overriding the fetch function used for requests', async () => { + const validMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, customFetch); + + expect(metadata).toEqual(validMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]!; + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + }); + + describe('discoverOAuthMetadata', () => { + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + it('returns metadata when discovery succeeds', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('returns metadata when discovery succeeds with path', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + expect(metadata).toEqual(validMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); + const [url, options] = calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('falls back to root discovery when path-aware discovery returns 404', async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should be path-aware + const [firstUrl, firstOptions] = calls[0]!; + expect(firstUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/path/name'); + expect(firstOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + + // Second call should be root fallback + const [secondUrl, secondOptions] = calls[1]!; + expect(secondUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(secondOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('returns undefined when both path-aware and root discovery return 404', async () => { + // First call (path-aware) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second call (root fallback) also returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/path/name'); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + }); + + it('does not fallback when the original URL is already at root path', async () => { + // First call (path-aware for root) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/'); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + }); + + it('does not fallback when the original URL has no path', async () => { + // First call (path-aware for no path) returns 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + expect(metadata).toBeUndefined(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + + const [url] = calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + }); + + it('falls back when path-aware discovery encounters CORS error (browser)', async () => { + withBrowserLikeEnvironment(); + // First call (path-aware) fails with TypeError (CORS) + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); + + // Retry path-aware without headers (simulating CORS retry) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second call (root fallback) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/deep/path'); + expect(metadata).toEqual(validMetadata); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(3); + + // Final call should be root fallback + const [lastUrl, lastOptions] = calls[2]!; + expect(lastUrl.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(lastOptions.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('returns metadata when first fetch fails but second without MCP header succeeds (browser CORS retry)', async () => { + withBrowserLikeEnvironment(); + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + return callCount === 1 + ? Promise.reject(new TypeError('Network error')) + : Promise.resolve({ + ok: true, + status: 200, + json: async () => validMetadata + }); + }); + + // Should succeed with the second call + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + expect(metadata).toEqual(validMetadata); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify first call had MCP header + expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); + }); + + it('throws an error when all fetch attempts fail (browser, retry throws non-TypeError)', async () => { + withBrowserLikeEnvironment(); + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + return callCount === 1 ? Promise.reject(new TypeError('First failure')) : Promise.reject(new Error('Second failure')); + }); + + // Should fail with the second error + await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('Second failure'); + + // Verify both calls were made + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('returns undefined when both CORS requests fail in fetchWithCorsRetry (browser)', async () => { + withBrowserLikeEnvironment(); + // fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS) + // simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError + mockFetch.mockImplementation(() => { + // Both the initial request with headers and retry without headers fail with CORS TypeError + return Promise.reject(new TypeError('Failed to fetch')); + }); + + // This should return undefined (the desired behavior after the fix) + const metadata = await discoverOAuthMetadata('https://auth.example.com/path'); + expect(metadata).toBeUndefined(); + }); + + it('returns undefined when discovery endpoint returns 404', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com'); + expect(metadata).toBeUndefined(); + }); + + it('throws on non-404 errors for root URL', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); + + await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); + }); + + it('falls back to root URL on 502 for path-aware discovery', async () => { + // Path-aware URL returns 502 (reverse proxy has no route for well-known path) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 })); + + // Root fallback URL succeeds + mockFetch.mockResolvedValueOnce(Response.json(validMetadata, { status: 200 })); + + const metadata = await discoverOAuthMetadata('https://auth.example.com/tenant1', { + authorizationServerUrl: 'https://auth.example.com/tenant1' + }); + + expect(metadata).toEqual(validMetadata); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('does not fall back on non-502 5xx for path-aware discovery', async () => { + // Path-aware URL returns 500 (overloaded server — should not retry) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); + + await expect( + discoverOAuthMetadata('https://auth.example.com/tenant1', { + authorizationServerUrl: 'https://auth.example.com/tenant1' + }) + ).rejects.toThrow('HTTP 500'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('throws when root fallback also returns error for path-aware discovery', async () => { + // Path-aware URL returns 502 (gateway error — triggers fallback) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 502 })); + + // Root fallback also returns 503 + mockFetch.mockResolvedValueOnce(new Response(null, { status: 503 })); + + await expect( + discoverOAuthMetadata('https://auth.example.com/tenant1', { + authorizationServerUrl: 'https://auth.example.com/tenant1' + }) + ).rejects.toThrow('HTTP 503'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('validates metadata schema', async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + { + // Missing required fields + issuer: 'https://auth.example.com' + }, + { status: 200 } + ) + ); + + await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow(); + }); + + it('supports overriding the fetch function used for requests', async () => { + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validMetadata + }); + + const metadata = await discoverOAuthMetadata('https://auth.example.com', {}, customFetch); + + expect(metadata).toEqual(validMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + }); + + describe('buildDiscoveryUrls', () => { + it('generates correct URLs for server without path', () => { + const urls = buildDiscoveryUrls('https://auth.example.com'); + + expect(urls).toHaveLength(2); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server', + type: 'oauth' + }, + { + url: 'https://auth.example.com/.well-known/openid-configuration', + type: 'oidc' + } + ]); + }); + + it('generates correct URLs for server with path', () => { + const urls = buildDiscoveryUrls('https://auth.example.com/tenant1'); + + expect(urls).toHaveLength(3); + expect(urls.map(u => ({ url: u.url.toString(), type: u.type }))).toEqual([ + { + url: 'https://auth.example.com/.well-known/oauth-authorization-server/tenant1', + type: 'oauth' + }, + { + url: 'https://auth.example.com/.well-known/openid-configuration/tenant1', + type: 'oidc' + }, + { + url: 'https://auth.example.com/tenant1/.well-known/openid-configuration', + type: 'oidc' + } + ]); + }); + + it('handles URL object input', () => { + const urls = buildDiscoveryUrls(new URL('https://auth.example.com/tenant1')); + + expect(urls).toHaveLength(3); + expect(urls[0]!.url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1'); + }); + }); + + describe('discoverAuthorizationServerMetadata', () => { + const validOAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + const validOpenIdMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + jwks_uri: 'https://auth.example.com/jwks', + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + it('tries URLs in order and returns first successful metadata', async () => { + // First OAuth URL (path before well-known) fails with 404 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + + // Second OIDC URL (path before well-known) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toEqual(validOpenIdMetadata); + + // Verify it tried the URLs in the correct order + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + expect(calls[0]![0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1'); + expect(calls[1]![0].toString()).toBe('https://auth.example.com/.well-known/openid-configuration/tenant1'); + }); + + it('continues on 4xx errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400 + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com'); + + expect(metadata).toEqual(validOpenIdMetadata); + }); + + it('continues on 502 and tries next URL', async () => { + // First URL (OAuth) returns 502 (reverse proxy with no route) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502, + text: async () => '' + }); + + // Second URL (OIDC) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOpenIdMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toEqual(validOpenIdMetadata); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('throws on non-502 5xx errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => '' + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when all URLs fail with 502', async () => { + // All URLs return 502 + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + text: async () => '' + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toBeUndefined(); + }); + + it('handles CORS errors with retry (browser)', async () => { + withBrowserLikeEnvironment(); + // First call fails with CORS + mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError('CORS error'))); + + // Retry without headers succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(2); + + // First call should have headers + expect(calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); + + // Second call should not have headers (CORS retry) + expect(calls[1]![1]?.headers).toBeUndefined(); + }); + + it('supports custom fetch function', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validOAuthMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { fetchFn: customFetch }); + + expect(metadata).toEqual(validOAuthMetadata); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('supports custom protocol version', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validOAuthMetadata + }); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com', { protocolVersion: '2025-01-01' }); + + expect(metadata).toEqual(validOAuthMetadata); + const calls = mockFetch.mock.calls; + const [, options] = calls[0]!; + expect(options.headers).toEqual({ + 'MCP-Protocol-Version': '2025-01-01', + Accept: 'application/json' + }); + }); + + it('returns undefined when all URLs fail with CORS errors (browser)', async () => { + withBrowserLikeEnvironment(); + // All fetch attempts fail with CORS errors (TypeError) + mockFetch.mockImplementation(() => Promise.reject(new TypeError('CORS error'))); + + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); + + expect(metadata).toBeUndefined(); + + // Verify that all discovery URLs were attempted + expect(mockFetch).toHaveBeenCalledTimes(6); // 3 URLs × 2 attempts each (with and without headers) + }); + + it('throws TypeError in non-browser environments instead of silently falling through (network failure)', async () => { + // In Node.js, a TypeError from fetch is a real error (DNS/connection), not CORS. + // Swallowing it and returning undefined would cause the caller to silently fall + // through to the next discovery URL, masking the actual network failure. + mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND auth.example.com'))); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com/tenant1')).rejects.toThrow(TypeError); + + // Only one call — no CORS retry attempted in non-browser environments + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('discoverOAuthServerInfo', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('returns auth server from RFC 9728 protected resource metadata', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com'); + expect(result.resourceMetadata).toEqual(validResourceMetadata); + expect(result.authorizationServerMetadata).toEqual(validAuthMetadata); + }); + + it('falls back to server URL when RFC 9728 is not supported', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // RFC 9728 returns 404 + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + ...validAuthMetadata, + issuer: 'https://resource.example.com' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + // Should fall back to server URL origin + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toBeDefined(); + }); + + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { + const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === overrideUrl.toString()) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com', { + resourceMetadataUrl: overrideUrl + }); + + expect(result.resourceMetadata).toEqual(validResourceMetadata); + // Verify the override URL was used instead of the default well-known path + expect(mockFetch.mock.calls[0]![0].toString()).toBe(overrideUrl.toString()); + }); + + it('propagates network failures instead of silently falling back (non-browser)', async () => { + // PRM discovery hits a DNS/connection failure. That's a transient reachability problem, + // not "server doesn't support RFC 9728" — the caller should see the real error rather + // than silently falling back to treating the MCP server URL as the auth server. + mockFetch.mockImplementation(() => Promise.reject(new TypeError('getaddrinfo ENOTFOUND resource.example.com'))); + + await expect(discoverOAuthServerInfo('https://resource.example.com')).rejects.toThrow(TypeError); + }); + }); + + describe('auth with provider authorization server URL caching', () => { + const validResourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }; + + const validAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + function createMockProvider(overrides: Partial = {}): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + ...overrides + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls saveDiscoveryState after discovery when provider implements it', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ saveDiscoveryState }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }) + ); + }); + + it('restores full discovery state from cache including resource metadata', async () => { + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadata: validResourceMetadata, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should NOT have called any discovery endpoints -- all from cache + const discoveryCalls = mockFetch.mock.calls.filter( + call => call[0].toString().includes('oauth-protected-resource') || call[0].toString().includes('oauth-authorization-server') + ); + expect(discoveryCalls).toHaveLength(0); + + // Verify the token request includes the resource parameter from cached metadata + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://resource.example.com/'); + }); + + it('re-saves enriched state when partial cache is supplemented with fetched metadata', async () => { + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + // Partial cache: auth server URL only, no metadata + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com' + }), + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validAuthMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + await auth(provider, { serverUrl: 'https://resource.example.com' }); + + // Should re-save with the enriched state including fetched metadata + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com', + authorizationServerMetadata: validAuthMetadata, + resourceMetadata: validResourceMetadata + }) + ); + }); + + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { + const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; + const provider = createMockProvider({ + // Cache has auth server URL + resourceMetadataUrl but no resourceMetadata + // (simulates browser redirect where PRM URL was saved but metadata wasn't) + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://auth.example.com', + resourceMetadataUrl: cachedPrmUrl, + authorizationServerMetadata: validAuthMetadata + }), + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + // The cached PRM URL should be used for resource metadata discovery + if (urlString === cachedPrmUrl) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => validResourceMetadata + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('AUTHORIZED'); + + // Should have used the cached PRM URL, not the default well-known path + const prmCalls = mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')); + expect(prmCalls).toHaveLength(1); + expect(prmCalls[0]![0].toString()).toBe(cachedPrmUrl); + }); + }); + + describe('selectClientAuthMethod', () => { + it('selects the correct client authentication method from client information', () => { + const clientInfo = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_endpoint_auth_method: 'client_secret_basic' + }; + const supportedMethods = ['client_secret_post', 'client_secret_basic', 'none']; + const authMethod = selectClientAuthMethod(clientInfo, supportedMethods); + expect(authMethod).toBe('client_secret_basic'); + }); + it('selects the correct client authentication method from supported methods', () => { + const clientInfo = { client_id: 'test-client-id' }; + const supportedMethods = ['client_secret_post', 'client_secret_basic', 'none']; + const authMethod = selectClientAuthMethod(clientInfo, supportedMethods); + expect(authMethod).toBe('none'); + }); + it('defaults to client_secret_basic when server omits token_endpoint_auth_methods_supported (RFC 8414 §2)', () => { + // RFC 8414 §2: if omitted, the default is client_secret_basic. + // RFC 6749 §2.3.1: servers MUST support HTTP Basic for clients with a secret. + const clientInfo = { client_id: 'test-client-id', client_secret: 'test-client-secret' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_basic'); + }); + it('defaults to none for public clients when server omits token_endpoint_auth_methods_supported', () => { + const clientInfo = { client_id: 'test-client-id' }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('none'); + }); + it('honors DCR-returned token_endpoint_auth_method even when server metadata omits supported methods', () => { + const clientInfo = { + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_endpoint_auth_method: 'client_secret_post' + }; + const authMethod = selectClientAuthMethod(clientInfo, []); + expect(authMethod).toBe('client_secret_post'); + }); + }); + + describe('startAuthorization', () => { + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/tkn', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + const validOpenIdMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/token', + jwks_uri: 'https://auth.example.com/jwks', + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + it('generates authorization URL with PKCE challenge', async () => { + const { authorizationUrl, codeVerifier } = await startAuthorization('https://auth.example.com', { + metadata: undefined, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server') + }); + + expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/authorize\?/); + expect(authorizationUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizationUrl.searchParams.get('code_challenge')).toBe('test_challenge'); + expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(authorizationUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(codeVerifier).toBe('test_verifier'); + }); + + it('includes scope parameter when provided', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + scope: 'read write profile' + }); + + expect(authorizationUrl.searchParams.get('scope')).toBe('read write profile'); + }); + + it('excludes scope parameter when not provided', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }); + + expect(authorizationUrl.searchParams.has('scope')).toBe(false); + }); + + it('includes state parameter when provided', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + state: 'foobar' + }); + + expect(authorizationUrl.searchParams.get('state')).toBe('foobar'); + }); + + it('excludes state parameter when not provided', async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }); + + expect(authorizationUrl.searchParams.has('state')).toBe(false); + }); + + // OpenID Connect requires that the user is prompted for consent if the scope includes 'offline_access' + it("includes consent prompt parameter if scope includes 'offline_access'", async () => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback', + scope: 'read write profile offline_access' + }); + + expect(authorizationUrl.searchParams.get('prompt')).toBe('consent'); + }); + + it.each([validMetadata, validOpenIdMetadata])('uses metadata authorization_endpoint when provided', async baseMetadata => { + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + metadata: baseMetadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }); + + expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/auth\?/); + }); + + it.each([validMetadata, validOpenIdMetadata])('validates response type support', async baseMetadata => { + const metadata = { + ...baseMetadata, + response_types_supported: ['token'] // Does not support 'code' + }; + + await expect( + startAuthorization('https://auth.example.com', { + metadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(/does not support response type/); + }); + + // https://github.com/modelcontextprotocol/typescript-sdk/issues/832 + it.each([validMetadata, validOpenIdMetadata])( + 'assumes supported code challenge methods includes S256 if absent', + async baseMetadata => { + const metadata = { + ...baseMetadata, + response_types_supported: ['code'], + code_challenge_methods_supported: undefined + }; + + const { authorizationUrl } = await startAuthorization('https://auth.example.com', { + metadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }); + + expect(authorizationUrl.toString()).toMatch(/^https:\/\/auth\.example\.com\/auth\?.+&code_challenge_method=S256/); + } + ); + + it.each([validMetadata, validOpenIdMetadata])( + 'validates supported code challenge methods includes S256 if present', + async baseMetadata => { + const metadata = { + ...baseMetadata, + response_types_supported: ['code'], + code_challenge_methods_supported: ['plain'] // Does not support 'S256' + }; + + await expect( + startAuthorization('https://auth.example.com', { + metadata, + clientInformation: validClientInfo, + redirectUrl: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(/does not support code challenge method/); + } + ); + }); + + describe('exchangeAuthorization', () => { + const validTokens: OAuthTokens = { + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123' + }; + + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + it('exchanges code for tokens', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server') + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token' + }), + expect.objectContaining({ + method: 'POST' + }) + ); + + const options = mockFetch.mock.calls[0]![1]; + expect(options.headers).toBeInstanceOf(Headers); + expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + expect(options.body).toBeInstanceOf(URLSearchParams); + + const body = options.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('allows for string "expires_in" values', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validTokens, expires_in: '3600' }) + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server') + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token' + }), + expect.objectContaining({ + method: 'POST' + }) + ); + + const options = mockFetch.mock.calls[0]![1]; + expect(options.headers).toBeInstanceOf(Headers); + expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + + const body = options.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(options.headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + it('exchanges code for tokens with auth', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: validMetadata, + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + addClientAuthentication: ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata + ) => { + headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret)); + params.set('example_url', typeof url === 'string' ? url : url.toString()); + params.set('example_metadata', metadata?.authorization_endpoint ?? ''); + params.set('example_param', 'example_value'); + } + }); + + expect(tokens).toEqual(validTokens); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token' + }), + expect.objectContaining({ + method: 'POST' + }) + ); + + const headers = mockFetch.mock.calls[0]![1].headers as Headers; + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + expect(headers.get('Authorization')).toBe('Basic Y2xpZW50MTIzOnNlY3JldDEyMw=='); + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('example_url')).toBe('https://auth.example.com/token'); + expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize'); + expect(body.get('example_param')).toBe('example_value'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('validates token response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + access_token: 'access123' + }) + }); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow(); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Token exchange failed').toResponseObject(), { status: 400 }) + ); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow('Token exchange failed'); + }); + + it('supports overriding the fetch function used for requests', async () => { + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server'), + fetchFn: customFetch + }); + + expect(tokens).toEqual(validTokens); + expect(customFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).not.toHaveBeenCalled(); + + const [url, options] = customFetch.mock.calls[0]!; + expect(url.toString()).toBe('https://auth.example.com/token'); + expect(options).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: expect.any(URLSearchParams) + }) + ); + + const body = options.body as URLSearchParams; + expect(body.get('grant_type')).toBe('authorization_code'); + expect(body.get('code')).toBe('code123'); + expect(body.get('code_verifier')).toBe('verifier123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect((options.headers as Headers).get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); + expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + }); + + describe('refreshAuthorization', () => { + const validTokens = { + access_token: 'newaccess123', + token_type: 'Bearer', + expires_in: 3600 + }; + const validTokensWithNewRefreshToken = { + ...validTokens, + refresh_token: 'newrefresh123' + }; + + const validMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + it('exchanges refresh token for new tokens', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + resource: new URL('https://api.example.com/mcp-server') + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token' + }), + expect.objectContaining({ + method: 'POST' + }) + ); + + const headers = mockFetch.mock.calls[0]![1].headers as Headers; + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + // Default auth method is client_secret_basic when no metadata provided (RFC 8414 §2) + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + expect(headers.get('Authorization')).toBe('Basic ' + btoa('client123:secret123')); + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('exchanges refresh token for new tokens with auth', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + metadata: validMetadata, + clientInformation: validClientInfo, + refreshToken: 'refresh123', + addClientAuthentication: ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata + ) => { + headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret)); + params.set('example_url', typeof url === 'string' ? url : url.toString()); + params.set('example_metadata', metadata?.authorization_endpoint ?? '?'); + params.set('example_param', 'example_value'); + } + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/token' + }), + expect.objectContaining({ + method: 'POST' + }) + ); + + const headers = mockFetch.mock.calls[0]![1].headers as Headers; + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + expect(headers.get('Authorization')).toBe('Basic Y2xpZW50MTIzOnNlY3JldDEyMw=='); + const body = mockFetch.mock.calls[0]![1].body as URLSearchParams; + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + expect(body.get('client_id')).toBeNull(); + expect(body.get('example_url')).toBe('https://auth.example.com/token'); + expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize'); + expect(body.get('example_param')).toBe('example_value'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('exchanges refresh token for new tokens and keep existing refresh token if none is returned', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const refreshToken = 'refresh123'; + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken + }); + + expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); + }); + + it('validates token response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + access_token: 'newaccess123' + }) + }); + + await expect( + refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123' + }) + ).rejects.toThrow(); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Token refresh failed').toResponseObject(), { status: 400 }) + ); + + await expect( + refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123' + }) + ).rejects.toThrow('Token refresh failed'); + }); + }); + + describe('registerClient', () => { + const validClientMetadata = { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + ...validClientMetadata + }; + + it('registers client and returns client information', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validClientInfo + }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata + }); + + expect(clientInfo).toEqual(validClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/register' + }), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(validClientMetadata) + }) + ); + }); + + it('includes scope in registration body when provided, overriding clientMetadata.scope', async () => { + const clientMetadataWithScope: OAuthClientMetadata = { + ...validClientMetadata, + scope: 'should-be-overridden' + }; + + const expectedClientInfo = { + ...validClientInfo, + scope: 'openid profile' + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => expectedClientInfo + }); + + const clientInfo = await registerClient('https://auth.example.com', { + clientMetadata: clientMetadataWithScope, + scope: 'openid profile' + }); + + expect(clientInfo).toEqual(expectedClientInfo); + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://auth.example.com/register' + }), + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ ...validClientMetadata, scope: 'openid profile' }) + }) + ); + }); + + it('validates client information response schema', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + // Missing required fields + client_secret: 'secret123' + }) + }); + + await expect( + registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata + }) + ).rejects.toThrow(); + }); + + it('throws when registration endpoint not available in metadata', async () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + await expect( + registerClient('https://auth.example.com', { + metadata, + clientMetadata: validClientMetadata + }) + ).rejects.toThrow(/does not support dynamic client registration/); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.ServerError, 'Dynamic client registration failed').toResponseObject(), { + status: 400 + }) + ); + + await expect( + registerClient('https://auth.example.com', { + clientMetadata: validClientMetadata + }) + ).rejects.toThrow('Dynamic client registration failed'); + }); + }); + + describe('auth function', () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn(), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('performs client_credentials with private_key_jwt when provider has addClientAuthentication', async () => { + // Arrange: metadata discovery for PRM and AS + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'cc_jwt_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Create a provider with client_credentials grant and addClientAuthentication + // redirectUrl returns undefined to indicate non-interactive flow + const ccProvider: OAuthClientProvider = { + get redirectUrl() { + // eslint-disable-next-line unicorn/no-useless-undefined + return undefined; + }, + get clientMetadata() { + return { + redirect_uris: [], + client_name: 'Test Client', + grant_types: ['client_credentials'] + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn().mockResolvedValue(undefined), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }), + addClientAuthentication: createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }) + }; + + const result = await auth(ccProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token request + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const [, init] = tokenCall!; + const body = init.body as URLSearchParams; + + // grant_type MUST be client_credentials, not the JWT-bearer grant + expect(body.get('grant_type')).toBe('client_credentials'); + // private_key_jwt client authentication parameters + expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + expect(body.get('client_assertion')).toBeTruthy(); + // resource parameter included based on PRM + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => { + // Setup: First call to protected resource metadata fails (404) + // Second call to auth server metadata succeeds + let callCount = 0; + mockFetch.mockImplementation(url => { + callCount++; + + const urlString = url.toString(); + + if (callCount === 1 && urlString.includes('/.well-known/oauth-protected-resource')) { + // First call - protected resource metadata fails with 404 + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (callCount === 2 && urlString.includes('/.well-known/oauth-authorization-server')) { + // Second call - auth server metadata succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (callCount === 3 && urlString.includes('/register')) { + // Third call - client registration succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue(undefined); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = vi.fn(); + + // Call the auth function + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + // Verify the result + expect(result).toBe('REDIRECT'); + + // Verify the sequence of calls + expect(mockFetch).toHaveBeenCalledTimes(3); + + // First call should be to protected resource metadata + expect(mockFetch.mock.calls[0]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Second call should be to oauth metadata at the root path + expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); + }); + + it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => { + // Setup: First call to protected resource metadata fails (404) + // When no authorization_servers are found in protected resource metadata, + // the auth server URL should be set to the base URL with "/" path + let callCount = 0; + mockFetch.mockImplementation(url => { + callCount++; + + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + // Protected resource metadata discovery attempts (both path-aware and root) fail with 404 + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + // Should fetch from base URL with root path, not the full serverUrl path + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://resource.example.com/', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + registration_endpoint: 'https://resource.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/register')) { + // Client registration succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call #${callCount}: ${urlString}`)); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue(undefined); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = vi.fn(); + + // Call the auth function with a server URL that has a path + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com/path/to/server' + }); + + // Verify the result + expect(result).toBe('REDIRECT'); + + // Verify that the oauth-authorization-server call uses the base URL + // This proves the fix: using new URL("/", serverUrl) instead of serverUrl + const authServerCall = mockFetch.mock.calls.find(call => + call[0].toString().includes('/.well-known/oauth-authorization-server') + ); + expect(authServerCall).toBeDefined(); + expect(authServerCall![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); + }); + + it('passes resource parameter through authorization flow', async () => { + // Mock successful metadata discovery - need to include protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for authorization flow + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL includes the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams) + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + + it('includes resource in token exchange when authorization code is provided', async () => { + // Mock successful metadata discovery and token exchange - need protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123' + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + authorizationCode: 'auth-code-123' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(body.get('code')).toBe('auth-code-123'); + }); + + it('includes resource in token refresh', async () => { + // Mock successful metadata discovery and token refresh - need protected resource metadata + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + }); + + it('skips default PRM resource validation when custom validateResourceURL is provided', async () => { + const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateResourceURL: mockValidateResourceURL + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://different-resource.example.com/mcp-server', // Mismatched resource + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (providerWithCustomValidation.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (providerWithCustomValidation.tokens as Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('REDIRECT'); + + // Verify custom validation method was called + expect(mockValidateResourceURL).toHaveBeenCalledWith( + new URL('https://api.example.com/mcp-server'), + 'https://different-resource.example.com/mcp-server' + ); + }); + + it('uses prefix of server URL from PRM resource as resource parameter', async () => { + // Mock successful metadata discovery with resource URL that is a prefix of requested URL + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + // Resource is a prefix of the requested server URL + resource: 'https://api.example.com/', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth with a URL that has the resource as prefix + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server/endpoint' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL includes the resource parameter from PRM + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams) + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + // Should use the PRM's resource value, not the full requested URL + expect(authUrl.searchParams.get('resource')).toBe('https://api.example.com/'); + }); + + it('excludes resource parameter when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery where protected resource metadata is not available (404) + // but authorization server metadata is available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + // Protected resource metadata not available + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth - should not include resource parameter + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL does NOT include the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams) + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + // Resource parameter should not be present when PRM is not available + expect(authUrl.searchParams.has('resource')).toBe(false); + }); + + it('excludes resource parameter in token exchange when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123' + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + authorizationCode: 'auth-code-123' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has('resource')).toBe(false); + expect(body.get('code')).toBe('auth-code-123'); + }); + + it('excludes resource parameter in token refresh when Protected Resource Metadata is not present', async () => { + // Mock metadata discovery - no protected resource metadata, but auth server metadata available + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + // Resource parameter should not be present when PRM is not available + expect(body.has('resource')).toBe(false); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe('refresh123'); + }); + + it('uses scopes_supported from PRM when scope is not provided', async () => { + // Mock PRM with scopes_supported + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/register')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods - no scope in clientMetadata + (mockProvider.clientInformation as Mock).mockResolvedValue(undefined); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = vi.fn(); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth without scope parameter + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL includes the scopes from PRM + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl?.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin'); + + // Verify the same scope was also used in the DCR request body + const registerCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/register')); + expect(registerCall).toBeDefined(); + const registerBody = JSON.parse(registerCall![1].body as string); + expect(registerBody.scope).toBe('mcp:read mcp:write mcp:admin'); + }); + + it('prefers explicit scope parameter over scopes_supported from PRM', async () => { + // Mock PRM with scopes_supported + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/register')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue(undefined); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = vi.fn(); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth with explicit scope parameter + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/', + scope: 'mcp:read' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the authorization URL uses the explicit scope, not scopes_supported + const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('mcp:read'); + }); + + it('fetches AS metadata with path from serverUrl when PRM returns external AS', async () => { + // Mock PRM discovery that returns an external AS + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://my.resource.com/.well-known/oauth-protected-resource/path/name') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://my.resource.com/', + authorization_servers: ['https://auth.example.com/oauth'] + }) + }); + } else if (urlString === 'https://auth.example.com/.well-known/oauth-authorization-server/path/name') { + // Path-aware discovery on AS with path from serverUrl + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as Mock).mockResolvedValue(undefined); + + // Call auth with serverUrl that has a path + const result = await auth(mockProvider, { + serverUrl: 'https://my.resource.com/path/name' + }); + + expect(result).toBe('REDIRECT'); + + // Verify the correct URLs were fetched + const calls = mockFetch.mock.calls; + + // First call should be to PRM + expect(calls[0]![0].toString()).toBe('https://my.resource.com/.well-known/oauth-protected-resource/path/name'); + + // Second call should be to AS metadata with the path from authorization server + expect(calls[1]![0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/oauth'); + }); + + it('supports overriding the fetch function used for requests', async () => { + const customFetch = vi.fn(); + + // Mock PRM discovery + customFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + + // Mock AS metadata discovery + customFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + client_name: 'Test Client', + redirect_uris: ['http://localhost:3000/callback'] + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client123', + client_secret: 'secret123' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('verifier123') + }; + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + fetchFn: customFetch + }); + + expect(result).toBe('REDIRECT'); + expect(customFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).not.toHaveBeenCalled(); + + // Verify custom fetch was called for PRM discovery + expect(customFetch.mock.calls[0]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Verify custom fetch was called for AS metadata discovery + expect(customFetch.mock.calls[1]![0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); + }); + }); + + describe('exchangeAuthorization with multiple client authentication methods', () => { + const validTokens = { + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123' + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + const metadataWithBasicOnly = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'] + }; + + const metadataWithPostOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ['client_secret_post'] + }; + + const metadataWithNoneOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ['none'] + }; + + const metadataWithAllBuiltinMethods = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'] + }; + + it('uses HTTP Basic authentication when client_secret_basic is supported', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: metadataWithBasicOnly, + clientInformation: validClientInfo, + authorizationCode: 'code123', + redirectUri: 'http://localhost:3000/callback', + codeVerifier: 'verifier123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check Authorization header + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + }); + + it('includes credentials in request body when client_secret_post is supported', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: metadataWithPostOnly, + clientInformation: validClientInfo, + authorizationCode: 'code123', + redirectUri: 'http://localhost:3000/callback', + codeVerifier: 'verifier123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check no Authorization header + expect(request.headers.get('Authorization')).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('secret123'); + }); + + it('it picks client_secret_basic when all builtin methods are supported', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: metadataWithAllBuiltinMethods, + clientInformation: validClientInfo, + authorizationCode: 'code123', + redirectUri: 'http://localhost:3000/callback', + codeVerifier: 'verifier123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check Authorization header - should use Basic auth as it's the most secure + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); + + // Credentials should not be in body when using Basic auth + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + }); + + it('uses public client authentication when none method is specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const clientInfoWithoutSecret = { + client_id: 'client123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + const tokens = await exchangeAuthorization('https://auth.example.com', { + metadata: metadataWithNoneOnly, + clientInformation: clientInfoWithoutSecret, + authorizationCode: 'code123', + redirectUri: 'http://localhost:3000/callback', + codeVerifier: 'verifier123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check no Authorization header + expect(request.headers.get('Authorization')).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBeNull(); + }); + + it('defaults to client_secret_basic when no auth methods specified (RFC 8414 §2)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + redirectUri: 'http://localhost:3000/callback', + codeVerifier: 'verifier123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // RFC 8414 §2: when token_endpoint_auth_methods_supported is omitted, + // the default is client_secret_basic (HTTP Basic auth, not body params) + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBeNull(); + expect(body.get('client_secret')).toBeNull(); + }); + }); + + describe('refreshAuthorization with multiple client authentication methods', () => { + const validTokens = { + access_token: 'newaccess123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'newrefresh123' + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + const metadataWithBasicOnly = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/auth', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + token_endpoint_auth_methods_supported: ['client_secret_basic'] + }; + + const metadataWithPostOnly = { + ...metadataWithBasicOnly, + token_endpoint_auth_methods_supported: ['client_secret_post'] + }; + + it('uses client_secret_basic for refresh token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + metadata: metadataWithBasicOnly, + clientInformation: validClientInfo, + refreshToken: 'refresh123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check Authorization header + const authHeader = request.headers.get('Authorization'); + const expected = 'Basic ' + btoa('client123:secret123'); + expect(authHeader).toBe(expected); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBeNull(); // should not be in body + expect(body.get('client_secret')).toBeNull(); // should not be in body + expect(body.get('refresh_token')).toBe('refresh123'); + }); + + it('uses client_secret_post for refresh token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + metadata: metadataWithPostOnly, + clientInformation: validClientInfo, + refreshToken: 'refresh123' + }); + + expect(tokens).toEqual(validTokens); + const request = mockFetch.mock.calls[0]![1]; + + // Check no Authorization header + expect(request.headers.get('Authorization')).toBeNull(); + + const body = request.body as URLSearchParams; + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('secret123'); + expect(body.get('refresh_token')).toBe('refresh123'); + }); + }); + + describe('RequestInit headers passthrough', () => { + it('custom headers from RequestInit are passed to auth discovery requests', async () => { + const { createFetchWithInit } = await import('@modelcontextprotocol/core'); + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + + // Create a wrapped fetch with custom headers + const wrappedFetch = createFetchWithInit(customFetch, { + headers: { + 'user-agent': 'MyApp/1.0', + 'x-custom-header': 'test-value' + } + }); + + await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); + + expect(customFetch).toHaveBeenCalledTimes(1); + const [url, options] = customFetch.mock.calls[0]!; + + expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + expect(options.headers).toMatchObject({ + 'user-agent': 'MyApp/1.0', + 'x-custom-header': 'test-value', + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('auth-specific headers override base headers from RequestInit', async () => { + const { createFetchWithInit } = await import('@modelcontextprotocol/core'); + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + + // Create a wrapped fetch with a custom Accept header + const wrappedFetch = createFetchWithInit(customFetch, { + headers: { + Accept: 'text/plain', + 'user-agent': 'MyApp/1.0' + } + }); + + await discoverAuthorizationServerMetadata('https://auth.example.com', { + fetchFn: wrappedFetch + }); + + expect(customFetch).toHaveBeenCalled(); + const [, options] = customFetch.mock.calls[0]!; + + // Auth-specific Accept header should override base Accept header + expect(options.headers).toMatchObject({ + Accept: 'application/json', // Auth-specific value wins + 'user-agent': 'MyApp/1.0', // Base value preserved + 'MCP-Protocol-Version': LATEST_PROTOCOL_VERSION + }); + }); + + it('other RequestInit options are passed through', async () => { + const { createFetchWithInit } = await import('@modelcontextprotocol/core'); + + const customFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + + // Create a wrapped fetch with various RequestInit options + const wrappedFetch = createFetchWithInit(customFetch, { + credentials: 'include', + mode: 'cors', + cache: 'no-cache', + headers: { + 'user-agent': 'MyApp/1.0' + } + }); + + await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); + + expect(customFetch).toHaveBeenCalledTimes(1); + const [, options] = customFetch.mock.calls[0]!; + + // All RequestInit options should be preserved + expect(options.credentials).toBe('include'); + expect(options.mode).toBe('cors'); + expect(options.cache).toBe('no-cache'); + expect(options.headers).toMatchObject({ + 'user-agent': 'MyApp/1.0' + }); + }); + }); + + describe('isHttpsUrl', () => { + it('returns true for valid HTTPS URL with path', () => { + expect(isHttpsUrl('https://example.com/client-metadata.json')).toBe(true); + }); + + it('returns true for HTTPS URL with query params', () => { + expect(isHttpsUrl('https://example.com/metadata?version=1')).toBe(true); + }); + + it('returns false for HTTPS URL without path', () => { + expect(isHttpsUrl('https://example.com')).toBe(false); + expect(isHttpsUrl('https://example.com/')).toBe(false); + }); + + it('returns false for HTTP URL', () => { + expect(isHttpsUrl('http://example.com/metadata')).toBe(false); + }); + + it('returns false for non-URL strings', () => { + expect(isHttpsUrl('not a url')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isHttpsUrl(undefined)).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isHttpsUrl('')).toBe(false); + }); + + it('returns false for javascript: scheme', () => { + expect(isHttpsUrl('javascript:alert(1)')).toBe(false); + }); + + it('returns false for data: scheme', () => { + expect(isHttpsUrl('data:text/html,')).toBe(false); + }); + }); + + describe('SEP-991: URL-based Client ID fallback logic', () => { + const validClientMetadata = { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client', + client_uri: 'https://example.com/client-metadata.json' + }; + + const mockProvider: OAuthClientProvider = { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + clientMetadataUrl: 'https://example.com/client-metadata.json', + get clientMetadata() { + return validClientMetadata; + }, + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn().mockResolvedValue(undefined), + redirectToAuthorization: vi.fn().mockResolvedValue(undefined), + saveCodeVerifier: vi.fn().mockResolvedValue(undefined), + codeVerifier: vi.fn().mockResolvedValue('verifier123') + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses URL-based client ID when server supports it', async () => { + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery to return support for URL-based client IDs + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true // SEP-991 support + }) + }); + + await auth(mockProvider, { + serverUrl: 'https://server.example.com' + }); + + // Should save URL-based client info + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'https://example.com/client-metadata.json' + }); + }); + + it('falls back to DCR when server does not support URL-based client IDs', async () => { + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery without SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + // No client_id_metadata_document_supported + }) + }); + + // Mock DCR response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + + await auth(mockProvider, { + serverUrl: 'https://server.example.com' + }); + + // Should save DCR client info + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }); + }); + + it('throws an error when clientMetadataUrl is not an HTTPS URL', async () => { + const providerWithInvalidUri = { + ...mockProvider, + clientMetadataUrl: 'http://example.com/metadata' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithInvalidUri, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata }); + }); + + it('throws an error when clientMetadataUrl has root pathname', async () => { + const providerWithRootPathname = { + ...mockProvider, + clientMetadataUrl: 'https://example.com/' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithRootPathname, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata }); + }); + + it('throws an error when clientMetadataUrl is not a valid URL', async () => { + const providerWithInvalidUrl = { + ...mockProvider, + clientMetadataUrl: 'not-a-valid-url' + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + await expect( + auth(providerWithInvalidUrl, { + serverUrl: 'https://server.example.com' + }) + ).rejects.toMatchObject({ code: OAuthErrorCode.InvalidClientMetadata }); + }); + + it('falls back to DCR when client_uri is missing', async () => { + const providerWithoutUri = { + ...mockProvider, + clientMetadataUrl: undefined + }; + + // Mock protected resource metadata discovery (404 to skip) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({}) + }); + + // Mock authorization server metadata discovery with SEP-991 support + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://server.example.com', + authorization_endpoint: 'https://server.example.com/authorize', + token_endpoint: 'https://server.example.com/token', + registration_endpoint: 'https://server.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + client_id_metadata_document_supported: true + }) + }); + + // Mock DCR response + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }) + }); + + await auth(providerWithoutUri, { + serverUrl: 'https://server.example.com' + }); + + // Should fall back to DCR + expect(mockProvider.saveClientInformation).toHaveBeenCalledWith({ + client_id: 'generated-uuid', + client_secret: 'generated-secret', + redirect_uris: ['http://localhost:3000/callback'] + }); + }); + }); + + describe('validateClientMetadataUrl', () => { + it('passes for valid HTTPS URL with path', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/.well-known/oauth-client')).not.toThrow(); + }); + + it('passes for valid HTTPS URL with multi-segment path', () => { + expect(() => validateClientMetadataUrl('https://example.com/clients/metadata.json')).not.toThrow(); + }); + + it('throws OAuthError for HTTP URL', () => { + expect(() => validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://client.example.com/.well-known/oauth-client'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('http://client.example.com/.well-known/oauth-client'); + } + }); + + it('throws OAuthError for non-URL string', () => { + expect(() => validateClientMetadataUrl('not-a-url')).toThrow(OAuthError); + try { + validateClientMetadataUrl('not-a-url'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('not-a-url'); + } + }); + + it('passes silently for empty string', () => { + expect(() => validateClientMetadataUrl('')).not.toThrow(); + }); + + it('throws OAuthError for root-path HTTPS URL with trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com/')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com/'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com/'); + } + }); + + it('throws OAuthError for root-path HTTPS URL without trailing slash', () => { + expect(() => validateClientMetadataUrl('https://client.example.com')).toThrow(OAuthError); + try { + validateClientMetadataUrl('https://client.example.com'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe(OAuthErrorCode.InvalidClientMetadata); + expect((error as OAuthError).message).toContain('https://client.example.com'); + } + }); + + it('passes silently for undefined', () => { + expect(() => validateClientMetadataUrl(undefined)).not.toThrow(); + }); + + it('error message matches expected format', () => { + expect(() => validateClientMetadataUrl('http://example.com/path')).toThrow(OAuthError); + try { + validateClientMetadataUrl('http://example.com/path'); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).message).toBe( + 'clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: http://example.com/path' + ); + } + }); + }); + + describe('determineScope', () => { + const baseClientMetadata = { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + describe('MCP Scope Selection Strategy', () => { + it('returns explicit requestedScope as-is (priority 1)', () => { + const result = determineScope({ + requestedScope: 'files:read', + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + clientMetadata: { + ...baseClientMetadata, + scope: 'fallback:scope' + } + }); + + expect(result).toBe('files:read'); + }); + + it('uses PRM scopes_supported when no explicit scope (priority 2)', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write', 'mcp:admin'] + }, + clientMetadata: { + ...baseClientMetadata, + scope: 'fallback:scope' + } + }); + + expect(result).toBe('mcp:read mcp:write mcp:admin'); + }); + + it('falls back to clientMetadata.scope when no PRM scopes (priority 3)', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/' + }, + clientMetadata: { + ...baseClientMetadata, + scope: 'client:default' + } + }); + + expect(result).toBe('client:default'); + }); + + it('returns undefined when no scope source available (priority 4)', () => { + const result = determineScope({ + clientMetadata: baseClientMetadata + }); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when PRM has no scopes_supported and clientMetadata has no scope', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/' + }, + clientMetadata: baseClientMetadata + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('SEP-2207: offline_access scope augmentation', () => { + const asMetadataWithOfflineAccess = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] as string[], + scopes_supported: ['openid', 'profile', 'offline_access'] + }; + + const asMetadataWithoutOfflineAccess = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] as string[], + scopes_supported: ['openid', 'profile'] + }; + + const clientMetadataWithRefreshToken = { + ...baseClientMetadata, + grant_types: ['authorization_code', 'refresh_token'] + }; + + it('augments explicit scope with offline_access', () => { + const result = determineScope({ + requestedScope: 'mcp:read mcp:write', + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBe('mcp:read mcp:write offline_access'); + }); + + it('adds offline_access when AS supports it and client grant_types includes refresh_token', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBe('mcp:read mcp:write offline_access'); + }); + + it('adds offline_access when using clientMetadata.scope fallback', () => { + const result = determineScope({ + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: { + ...clientMetadataWithRefreshToken, + scope: 'mcp:tools' + } + }); + + expect(result).toBe('mcp:tools offline_access'); + }); + + it('does NOT augment when no other scopes are present', () => { + const result = determineScope({ + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBeUndefined(); + }); + + it('does NOT augment when AS metadata lacks offline_access', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + authServerMetadata: asMetadataWithoutOfflineAccess, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBe('mcp:read mcp:write'); + }); + + it('does NOT augment when AS metadata is undefined', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBe('mcp:read mcp:write'); + }); + + it('does NOT augment when offline_access already in clientMetadata.scope', () => { + const result = determineScope({ + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: { + ...clientMetadataWithRefreshToken, + scope: 'mcp:tools offline_access' + } + }); + + expect(result).toBe('mcp:tools offline_access'); + }); + + it('does NOT augment when non-compliant PRM already includes offline_access', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'offline_access', 'mcp:write'] + }, + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: clientMetadataWithRefreshToken + }); + + expect(result).toBe('mcp:read offline_access mcp:write'); + }); + + it('does NOT augment when grant_types omits refresh_token', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: { + ...baseClientMetadata, + grant_types: ['authorization_code'] + } + }); + + expect(result).toBe('mcp:read mcp:write'); + }); + + it('does NOT augment when grant_types is undefined (respects OAuth defaults)', () => { + const result = determineScope({ + resourceMetadata: { + resource: 'https://api.example.com/', + scopes_supported: ['mcp:read', 'mcp:write'] + }, + authServerMetadata: asMetadataWithOfflineAccess, + clientMetadata: baseClientMetadata + }); + + expect(result).toBe('mcp:read mcp:write'); + }); + }); + }); +}); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts new file mode 100644 index 0000000..16c3ea3 --- /dev/null +++ b/packages/client/test/client/authExtensions.test.ts @@ -0,0 +1,768 @@ +import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; +import { describe, expect, it, vi } from 'vitest'; + +import { auth } from '../../src/client/auth.js'; +import { + ClientCredentialsProvider, + createPrivateKeyJwtAuth, + CrossAppAccessProvider, + PrivateKeyJwtProvider, + StaticPrivateKeyJwtProvider +} from '../../src/client/authExtensions.js'; + +const RESOURCE_SERVER_URL = 'https://resource.example.com/'; +const AUTH_SERVER_URL = 'https://auth.example.com'; + +describe('auth-extensions providers (end-to-end with auth())', () => { + it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + expect(params.get('client_assertion')).toBeNull(); + + const headers = new Headers(init?.headers); + const authHeader = headers.get('Authorization'); + expect(authHeader).toBeTruthy(); + + const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64'); + expect(authHeader).toBe(`Basic ${expectedCredentials}`); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); + + it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client', + scope: 'read write' + }); + + expect(provider.clientMetadata.scope).toBe('read write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('read write'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + + it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client' + }); + + let assertionFromRequest: string | null = null; + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + + assertionFromRequest = params.get('client_assertion'); + expect(assertionFromRequest).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const parts = assertionFromRequest!.split('.'); + expect(parts).toHaveLength(3); + + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBeNull(); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + expect(assertionFromRequest).toBeTruthy(); + }); + + it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client', + scope: 'openid profile' + }); + + expect(provider.clientMetadata.scope).toBe('openid profile'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('openid profile'); + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + + it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'none', + clientName: 'private-key-jwt-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + await expect( + auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }) + ).rejects.toThrow('Unsupported algorithm none'); + }); + + it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBeNull(); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); + + it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client', + scope: 'api:read api:write' + }); + + expect(provider.clientMetadata.scope).toBe('api:read api:write'); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('scope')).toBe('api:read api:write'); + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); +}); + +describe('createPrivateKeyJwtAuth', () => { + const baseOptions = { + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }; + + it('creates an addClientAuthentication function that sets JWT assertion params', async () => { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + + const headers = new Headers(); + const params = new URLSearchParams(); + + await addClientAuth(headers, params, 'https://auth.example.com/token', undefined); + + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + // Verify JWT structure (three dot-separated segments) + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('throws when globalThis.crypto is not available', async () => { + // Temporarily remove globalThis.crypto to simulate older Node.js runtimes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalAny = globalThis as any; + const originalCrypto = globalAny.crypto; + // Use delete so that typeof globalThis.crypto === 'undefined' + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete globalAny.crypto; + + try { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + const params = new URLSearchParams(); + + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + 'crypto is not available, please ensure you have Web Crypto API support for older Node.js versions' + ); + } finally { + // Restore original crypto to avoid affecting other tests + globalAny.crypto = originalCrypto; + } + }); + + it('creates a signed JWT when using a Uint8Array HMAC key', async () => { + const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long'); + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: secret, + alg: 'HS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('creates a signed JWT when using a symmetric JWK key', async () => { + const jwk: Record = { + kty: 'oct', + // "a-string-secret-at-least-256-bits-long" base64url-encoded + k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc', + alg: 'HS256' + }; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: jwk, + alg: 'HS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('creates a signed JWT when using an RSA PEM private key', async () => { + // Generate an RSA key pair on the fly + const jose = await import('jose'); + const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true }); + const pem = await jose.exportPKCS8(privateKey); + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: pem, + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('uses metadata.issuer as audience when available', async () => { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', { + issuer: 'https://issuer.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }); + + const assertion = params.get('client_assertion')!; + // Decode the payload to verify audience + const [, payloadB64] = assertion.split('.'); + const payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString()); + expect(payload.aud).toBe('https://issuer.example.com'); + }); + + it('throws when using an unsupported algorithm', async () => { + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'none' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + 'Unsupported algorithm none' + ); + }); + + it('throws when jose cannot import an invalid RSA PEM key', async () => { + const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----'; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: badPem, + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + /cannot be part of a valid base64|Invalid character/ + ); + }); + + it('throws when jose cannot import a mismatched JWK key', async () => { + const jwk: Record = { + kty: 'oct', + k: 'c2VjcmV0LWtleQ', // "secret-key" base64url + alg: 'HS256' + }; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: jwk, + // Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + /Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/ + ); + }); + + it('includes custom claims in the signed JWT assertion', async () => { + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256', + claims: { tenant_id: 'org-123', role: 'admin' } + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion'); + expect(assertion).toBeTruthy(); + + const jose = await import('jose'); + const decoded = jose.decodeJwt(assertion!); + expect(decoded.tenant_id).toBe('org-123'); + expect(decoded.role).toBe('admin'); + expect(decoded.iss).toBe('client-id'); + expect(decoded.sub).toBe('client-id'); + }); + + it('passes custom claims through PrivateKeyJwtProvider', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + claims: { tenant_id: 'org-456' } + }); + + const params = new URLSearchParams(); + await provider.addClientAuthentication(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion'); + expect(assertion).toBeTruthy(); + + const jose = await import('jose'); + const decoded = jose.decodeJwt(assertion!); + expect(decoded.tenant_id).toBe('org-456'); + expect(decoded.iss).toBe('client-id'); + }); +}); + +describe('CrossAppAccessProvider', () => { + const RESOURCE_SERVER_URL = 'https://mcp.chat.example/'; + const AUTH_SERVER_URL = 'https://auth.chat.example'; + const IDP_URL = 'https://idp.example.com'; + + it('successfully authenticates using Cross-App Access flow', async () => { + let assertionCallbackInvoked = false; + let jwtGrantUsed = ''; + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + assertionCallbackInvoked = true; + expect(ctx.authorizationServerUrl).toBe(AUTH_SERVER_URL); + expect(ctx.resourceUrl).toBe(RESOURCE_SERVER_URL); + expect(ctx.scope).toBeUndefined(); + expect(ctx.fetchFn).toBeDefined(); + return 'jwt-authorization-grant-token'; + }, + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret', + clientName: 'xaa-test-client' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + + jwtGrantUsed = params.get('assertion') || ''; + expect(jwtGrantUsed).toBe('jwt-authorization-grant-token'); + + // Verify client authentication + const headers = new Headers(init?.headers); + const authHeader = headers.get('Authorization'); + expect(authHeader).toBeTruthy(); + + const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64'); + expect(authHeader).toBe(`Basic ${expectedCredentials}`); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + expect(assertionCallbackInvoked).toBe(true); + expect(jwtGrantUsed).toBe('jwt-authorization-grant-token'); + + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); + + it('passes scope to assertion callback', async () => { + let capturedScope: string | undefined; + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + capturedScope = ctx.scope; + return 'jwt-grant'; + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + scope: 'chat.read chat.history', + fetchFn: fetchMock + }); + + expect(capturedScope).toBe('chat.read chat.history'); + }); + + it('passes custom fetchFn to assertion callback', async () => { + let capturedFetchFn: unknown; + + const customFetch = vi.fn(fetch); + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + // Wrap the mock to track calls + const wrappedFetch = vi.fn((...args: Parameters) => fetchMock(...args)); + + const provider = new CrossAppAccessProvider({ + assertion: async ctx => { + capturedFetchFn = ctx.fetchFn; + return 'jwt-grant'; + }, + clientId: 'client', + clientSecret: 'secret', + fetchFn: customFetch + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: wrappedFetch + }); + + // The assertion callback should receive the custom fetch function + expect(capturedFetchFn).toBe(customFetch); + }); + + it('throws error when authorization server URL is not available', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + // Try to call prepareTokenRequest without going through auth() + await expect(provider.prepareTokenRequest()).rejects.toThrow( + 'Authorization server URL not available. Ensure auth() has been called first.' + ); + }); + + it('throws error when resource URL is not available', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + // Manually set authorization server URL but not resource URL + provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); + + await expect(provider.prepareTokenRequest()).rejects.toThrow( + 'Resource URL not available — server may not implement RFC 9728 Protected Resource Metadata' + ); + }); + + it('stores and retrieves authorization server URL', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.authorizationServerUrl?.()).toBeUndefined(); + + provider.saveAuthorizationServerUrl?.(AUTH_SERVER_URL); + expect(provider.authorizationServerUrl?.()).toBe(AUTH_SERVER_URL); + }); + + it('stores and retrieves resource URL', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.resourceUrl?.()).toBeUndefined(); + + provider.saveResourceUrl?.(RESOURCE_SERVER_URL); + expect(provider.resourceUrl?.()).toBe(RESOURCE_SERVER_URL); + }); + + it('has correct client metadata', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret', + clientName: 'custom-xaa-client' + }); + + const metadata = provider.clientMetadata; + expect(metadata.client_name).toBe('custom-xaa-client'); + expect(metadata.redirect_uris).toEqual([]); + expect(metadata.grant_types).toEqual(['urn:ietf:params:oauth:grant-type:jwt-bearer']); + expect(metadata.token_endpoint_auth_method).toBe('client_secret_basic'); + }); + + it('uses default client name when not provided', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.clientMetadata.client_name).toBe('cross-app-access-client'); + }); + + it('returns undefined for redirectUrl (non-interactive flow)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(provider.redirectUrl).toBeUndefined(); + }); + + it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(() => provider.redirectToAuthorization()).toThrow('redirectToAuthorization is not used for jwt-bearer flow'); + }); + + it('throws error for codeVerifier (not used in jwt-bearer)', () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + expect(() => provider.codeVerifier()).toThrow('codeVerifier is not used for jwt-bearer flow'); + }); + + it('handles assertion callback errors gracefully', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => { + throw new Error('Failed to get ID token from IdP'); + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL + }); + + await expect( + auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }) + ).rejects.toThrow('Failed to get ID token from IdP'); + }); + + it('allows assertion callback to return a promise', async () => { + const provider = new CrossAppAccessProvider({ + assertion: ctx => { + return new Promise(resolve => { + setTimeout(() => resolve('async-jwt-grant'), 10); + }); + }, + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params.get('assertion')).toBe('async-jwt-grant'); + } + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + }); + + it('includes scope in token request params when provided', async () => { + const provider = new CrossAppAccessProvider({ + assertion: async () => 'jwt-grant', + clientId: 'client', + clientSecret: 'secret' + }); + + const fetchMock = createMockOAuthFetch({ + resourceServerUrl: RESOURCE_SERVER_URL, + authServerUrl: AUTH_SERVER_URL, + onTokenRequest: async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params.get('scope')).toBe('chat.read chat.write'); + } + }); + + await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + scope: 'chat.read chat.write', + fetchFn: fetchMock + }); + }); +}); diff --git a/packages/client/test/client/barrelClean.test.ts b/packages/client/test/client/barrelClean.test.ts new file mode 100644 index 0000000..6a7dc02 --- /dev/null +++ b/packages/client/test/client/barrelClean.test.ts @@ -0,0 +1,55 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeAll, describe, expect, test } from 'vitest'; + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const distDir = join(pkgDir, 'dist'); +const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/; + +function chunkImportsOf(entryPath: string): string[] { + const visited = new Set(); + const queue = [entryPath]; + while (queue.length > 0) { + const file = queue.shift()!; + if (visited.has(file)) continue; + visited.add(file); + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + queue.push(join(dirname(file), m[1]!)); + } + } + visited.delete(entryPath); + return [...visited]; +} + +describe('@modelcontextprotocol/client root entry is browser-safe', () => { + beforeAll(() => { + if (!existsSync(join(distDir, 'index.mjs')) || !existsSync(join(distDir, 'stdio.mjs'))) { + execFileSync('pnpm', ['build'], { cwd: pkgDir, stdio: 'inherit' }); + } + }, 60_000); + + test('dist/index.mjs contains no process-spawning runtime imports', () => { + const entry = join(distDir, 'index.mjs'); + expect(readFileSync(entry, 'utf8')).not.toMatch(NODE_ONLY); + }); + + test('chunks transitively imported by dist/index.mjs contain no process-spawning runtime imports', () => { + const entry = join(distDir, 'index.mjs'); + for (const chunk of chunkImportsOf(entry)) { + expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) + ); + } + }); + + test('dist/stdio.mjs exists and exports StdioClientTransport', () => { + const stdio = readFileSync(join(distDir, 'stdio.mjs'), 'utf8'); + expect(stdio).toMatch(/\bStdioClientTransport\b/); + expect(stdio).toMatch(/\bgetDefaultEnvironment\b/); + expect(stdio).toMatch(/\bDEFAULT_INHERITED_ENV_VARS\b/); + }); +}); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts new file mode 100644 index 0000000..1b595c4 --- /dev/null +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -0,0 +1,428 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess.js'; + +describe('crossAppAccess', () => { + describe('requestJwtAuthorizationGrant', () => { + it('successfully exchanges ID token for JWT Authorization Grant', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + token_type: 'N_A', + expires_in: 300, + scope: 'chat.read chat.history' + }) + } as Response); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...', + clientId: 'my-idp-client', + clientSecret: 'my-idp-secret', + scope: 'chat.read chat.history', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(result.expiresIn).toBe(300); + expect(result.scope).toBe('chat.read chat.history'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://idp.example.com/token'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:token-exchange'); + expect(body.get('requested_token_type')).toBe('urn:ietf:params:oauth:token-type:id-jag'); + expect(body.get('audience')).toBe('https://auth.chat.example/'); + expect(body.get('resource')).toBe('https://mcp.chat.example/'); + expect(body.get('subject_token')).toBe('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(body.get('subject_token_type')).toBe('urn:ietf:params:oauth:token-type:id_token'); + expect(body.get('client_id')).toBe('my-idp-client'); + expect(body.get('client_secret')).toBe('my-idp-secret'); + expect(body.get('scope')).toBe('chat.read chat.history'); + }); + + it('works without optional scope parameter', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }) + } as Response); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + + const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string); + expect(body.get('scope')).toBeNull(); + }); + + it('omits client_secret from body when not provided (public client)', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }) + } as Response); + + await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'public-client', + fetchFn: mockFetch + }); + + const body = new URLSearchParams(mockFetch.mock.calls[0]![1]?.body as string); + expect(body.get('client_id')).toBe('public-client'); + // Must be absent — not empty string, not the literal "undefined" + expect(body.has('client_secret')).toBe(false); + }); + + it('throws error when issued_token_type is incorrect', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + access_token: 'token', + token_type: 'N_A' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Invalid token exchange response'); + }); + + it('accepts token_type other than N_A (issued_token_type is the real check)', async () => { + // RFC 6749 §5.1: token_type is case-insensitive; RFC 8693 §2.2.1: informational + // when the issued token isn't an access token. Real IdPs return 'n_a', 'Bearer', etc. + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'n_a' + }) + } as Response); + + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + }); + + it('throws error when access_token is missing', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + token_type: 'N_A' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Invalid token exchange response'); + }); + + it('handles OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'Audience validation failed' + }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Token exchange failed: invalid_grant - Audience validation failed'); + }); + + it('handles non-OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ message: 'Internal server error' }) + } as Response); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: 'https://idp.example.com/token', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Token exchange failed with status 500'); + }); + }); + + describe('discoverAndRequestJwtAuthGrant', () => { + it('discovers token endpoint and performs token exchange', async () => { + const mockFetch = vi.fn(); + + // Mock discovery response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize', + token_endpoint: 'https://idp.example.com/token', + jwks_uri: 'https://idp.example.com/jwks', + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange'] + }) + } as Response); + + // Mock token exchange response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A', + expires_in: 300 + }) + } as Response); + + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(result.expiresIn).toBe(300); + + expect(mockFetch).toHaveBeenCalledTimes(2); + // First call is discovery + expect(String(mockFetch.mock.calls[0]![0])).toContain('.well-known/oauth-authorization-server'); + // Second call is token exchange + expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token'); + }); + + it('throws error when token endpoint is not discovered', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + issuer: 'https://idp.example.com', + authorization_endpoint: 'https://idp.example.com/authorize' + // Missing token_endpoint and response_types_supported + }) + } as Response); + + await expect( + discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://idp.example.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow(); // Zod validation error + }); + }); + + describe('exchangeJwtAuthGrant', () => { + it('exchanges JAG for access token using client_secret_basic by default', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'mcp-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'chat.read chat.history' + }) + } as Response); + + const result = await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret', + fetchFn: mockFetch + }); + + expect(result.access_token).toBe('mcp-access-token'); + expect(result.token_type).toBe('Bearer'); + expect(result.expires_in).toBe(3600); + expect(result.scope).toBe('chat.read chat.history'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [url, init] = mockFetch.mock.calls[0]!; + expect(url).toBe('https://auth.chat.example/token'); + expect(init?.method).toBe('POST'); + + // SEP-990 conformance: credentials in Authorization header, NOT in body + const headers = new Headers(init?.headers as Headers); + const expectedCredentials = Buffer.from('my-mcp-client:my-mcp-secret').toString('base64'); + expect(headers.get('Authorization')).toBe(`Basic ${expectedCredentials}`); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer'); + expect(body.get('assertion')).toBe('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'); + expect(body.has('client_id')).toBe(false); + expect(body.has('client_secret')).toBe(false); + }); + + it('supports client_secret_post when explicitly requested', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'mcp-access-token', + token_type: 'Bearer' + }) + } as Response); + + await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'my-mcp-client', + clientSecret: 'my-mcp-secret', + authMethod: 'client_secret_post', + fetchFn: mockFetch + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers as Headers); + expect(headers.get('Authorization')).toBeNull(); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('client_id')).toBe('my-mcp-client'); + expect(body.get('client_secret')).toBe('my-mcp-secret'); + }); + + it('supports authMethod none for public clients', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'mcp-access-token', + token_type: 'Bearer' + }) + } as Response); + + await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'my-public-client', + authMethod: 'none', + fetchFn: mockFetch + }); + + const [, init] = mockFetch.mock.calls[0]!; + const headers = new Headers(init?.headers as Headers); + expect(headers.get('Authorization')).toBeNull(); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('client_id')).toBe('my-public-client'); + expect(body.has('client_secret')).toBe(false); + }); + + it('handles OAuth error responses', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'JWT signature verification failed' + }) + } as Response); + + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'invalid-jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed'); + }); + + it('validates token response with schema', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + // Missing required fields + token_type: 'Bearer' + }) + } as Response); + + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }) + ).rejects.toThrow('Invalid token response'); + }); + }); +}); diff --git a/packages/client/test/client/crossSpawn.test.ts b/packages/client/test/client/crossSpawn.test.ts new file mode 100644 index 0000000..a6d0272 --- /dev/null +++ b/packages/client/test/client/crossSpawn.test.ts @@ -0,0 +1,205 @@ +import type { ChildProcess } from 'node:child_process'; + +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import spawn from 'cross-spawn'; +import type { Mock, MockedFunction } from 'vitest'; + +import { getDefaultEnvironment, StdioClientTransport } from '../../src/client/stdio.js'; + +// mock cross-spawn +vi.mock('cross-spawn'); +const mockSpawn = spawn as unknown as MockedFunction; + +describe('StdioClientTransport using cross-spawn', () => { + beforeEach(() => { + // mock cross-spawn's return value + mockSpawn.mockImplementation(() => { + const mockProcess: { + on: Mock; + stdin?: { on: Mock; write: Mock }; + stdout?: { on: Mock }; + stderr?: null; + } = { + on: vi.fn((event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + return mockProcess; + }), + stdin: { + on: vi.fn(), + write: vi.fn().mockReturnValue(true) + }, + stdout: { + on: vi.fn() + }, + stderr: null + }; + return mockProcess as unknown as ChildProcess; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('should call cross-spawn correctly', async () => { + const transport = new StdioClientTransport({ + command: 'test-command', + args: ['arg1', 'arg2'] + }); + + await transport.start(); + + // verify spawn is called correctly + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + ['arg1', 'arg2'], + expect.objectContaining({ + shell: false + }) + ); + }); + + test('should pass environment variables correctly', async () => { + const customEnv = { TEST_VAR: 'test-value' }; + const transport = new StdioClientTransport({ + command: 'test-command', + env: customEnv + }); + + await transport.start(); + + // verify environment variables are merged correctly + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + env: { + ...getDefaultEnvironment(), + ...customEnv + } + }) + ); + }); + + test('should use default environment when env is undefined', async () => { + const transport = new StdioClientTransport({ + command: 'test-command', + env: undefined + }); + + await transport.start(); + + // verify default environment is used + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + env: getDefaultEnvironment() + }) + ); + }); + + test('should send messages correctly', async () => { + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + // get the mock process object + const mockProcess: { + on: Mock; + stdin: { + on: Mock; + write: Mock; + once: Mock; + }; + stdout: { + on: Mock; + }; + stderr: null; + } = { + on: vi.fn((event: string, callback: () => void) => { + if (event === 'spawn') { + callback(); + } + return mockProcess; + }), + stdin: { + on: vi.fn(), + write: vi.fn().mockReturnValue(true), + once: vi.fn() + }, + stdout: { + on: vi.fn() + }, + stderr: null + }; + + mockSpawn.mockReturnValue(mockProcess as unknown as ChildProcess); + + await transport.start(); + + // 关键修复:确保 jsonrpc 是字面量 "2.0" + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'test-id', + method: 'test-method' + }; + + await transport.send(message); + + // verify message is sent correctly + expect(mockProcess.stdin.write).toHaveBeenCalled(); + }); + + describe('windowsHide', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform + }); + }); + + test('should set windowsHide to true on Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: true + }) + ); + }); + + test('should set windowsHide to false on non-Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux' + }); + + const transport = new StdioClientTransport({ + command: 'test-command' + }); + + await transport.start(); + + expect(mockSpawn).toHaveBeenCalledWith( + 'test-command', + [], + expect.objectContaining({ + windowsHide: false + }) + ); + }); + }); +}); diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts new file mode 100644 index 0000000..64bbfa6 --- /dev/null +++ b/packages/client/test/client/middleware.test.ts @@ -0,0 +1,1119 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; +import type { Mocked, MockedFunction, MockInstance } from 'vitest'; + +import type { OAuthClientProvider } from '../../src/client/auth.js'; +import { applyMiddlewares, createMiddleware, withLogging, withOAuth } from '../../src/client/middleware.js'; + +vi.mock('../../src/client/auth.js', async () => { + const actual = await vi.importActual('../../src/client/auth.js'); + return { + ...actual, + auth: vi.fn(), + extractWWWAuthenticateParams: vi.fn() + }; +}); + +import { auth, extractWWWAuthenticateParams } from '../../src/client/auth.js'; + +const mockAuth = auth as MockedFunction; +const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction; + +describe('withOAuth', () => { + let mockProvider: Mocked; + let mockFetch: MockedFunction; + + beforeEach(() => { + vi.clearAllMocks(); + + mockProvider = { + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + tokens: vi.fn(), + saveTokens: vi.fn(), + clientInformation: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }; + + mockFetch = vi.fn(); + }); + + it('should add Authorization header when tokens are available (with explicit baseUrl)', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer test-token'); + }); + + it('should add Authorization header when tokens are available (without baseUrl)', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + // Test without baseUrl - should extract from request URL + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer test-token'); + }); + + it('should handle requests without tokens (without baseUrl)', async () => { + mockProvider.tokens.mockResolvedValue(undefined); + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + // Test without baseUrl + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBeNull(); + }); + + it('should retry request after successful auth on 401 response (with explicit baseUrl)', async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: 'old-token', + token_type: 'Bearer', + expires_in: 3600 + }) + .mockResolvedValueOnce({ + access_token: 'new-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const unauthorizedResponse = new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Bearer realm="oauth"' } + }); + const successResponse = new Response('success', { status: 200 }); + + mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse); + + const mockWWWAuthenticateParams = { + resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'), + scope: 'read' + }; + mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams); + mockAuth.mockResolvedValue('AUTHORIZED'); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + const result = await enhancedFetch('https://api.example.com/data'); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: 'https://api.example.com', + resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, + scope: mockWWWAuthenticateParams.scope, + fetchFn: mockFetch + }); + + // Verify the retry used the new token + const retryCallArgs = mockFetch.mock.calls[1]; + const retryHeaders = retryCallArgs![1]?.headers as Headers; + expect(retryHeaders.get('Authorization')).toBe('Bearer new-token'); + }); + + it('should retry request after successful auth on 401 response (without baseUrl)', async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: 'old-token', + token_type: 'Bearer', + expires_in: 3600 + }) + .mockResolvedValueOnce({ + access_token: 'new-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const unauthorizedResponse = new Response('Unauthorized', { + status: 401, + headers: { 'www-authenticate': 'Bearer realm="oauth"' } + }); + const successResponse = new Response('success', { status: 200 }); + + mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse); + + const mockWWWAuthenticateParams = { + resourceMetadataUrl: new URL('https://oauth.example.com/.well-known/oauth-protected-resource'), + scope: 'read' + }; + mockExtractWWWAuthenticateParams.mockReturnValue(mockWWWAuthenticateParams); + mockAuth.mockResolvedValue('AUTHORIZED'); + + // Test without baseUrl - should extract from request URL + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + const result = await enhancedFetch('https://api.example.com/data'); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: 'https://api.example.com', // Should be extracted from request URL + resourceMetadataUrl: mockWWWAuthenticateParams.resourceMetadataUrl, + scope: mockWWWAuthenticateParams.scope, + fetchFn: mockFetch + }); + + // Verify the retry used the new token + const retryCallArgs = mockFetch.mock.calls[1]; + const retryHeaders = retryCallArgs![1]?.headers as Headers; + expect(retryHeaders.get('Authorization')).toBe('Bearer new-token'); + }); + + it('should throw UnauthorizedError when auth returns REDIRECT (without baseUrl)', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + mockExtractWWWAuthenticateParams.mockReturnValue({}); + mockAuth.mockResolvedValue('REDIRECT'); + + // Test without baseUrl + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow( + 'Authentication requires user authorization - redirect initiated' + ); + }); + + it('should throw UnauthorizedError when auth fails', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + mockExtractWWWAuthenticateParams.mockReturnValue({}); + mockAuth.mockRejectedValue(new Error('Network error')); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Failed to re-authenticate: Network error'); + }); + + it('should handle persistent 401 responses after auth', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + // Always return 401 + mockFetch.mockResolvedValue(new Response('Unauthorized', { status: 401 })); + mockExtractWWWAuthenticateParams.mockReturnValue({}); + mockAuth.mockResolvedValue('AUTHORIZED'); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow( + 'Authentication failed for https://api.example.com/data' + ); + + // Should have made initial request + 1 retry after auth = 2 total + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledTimes(1); + }); + + it('should preserve original request method and body', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + const requestBody = JSON.stringify({ data: 'test' }); + await enhancedFetch('https://api.example.com/data', { + method: 'POST', + body: requestBody, + headers: { 'Content-Type': 'application/json' } + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + method: 'POST', + body: requestBody, + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('Authorization')).toBe('Bearer test-token'); + }); + + it('should handle non-401 errors normally', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const serverErrorResponse = new Response('Server Error', { status: 500 }); + mockFetch.mockResolvedValue(serverErrorResponse); + + const enhancedFetch = withOAuth(mockProvider, 'https://api.example.com')(mockFetch); + + const result = await enhancedFetch('https://api.example.com/data'); + + expect(result).toBe(serverErrorResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockAuth).not.toHaveBeenCalled(); + }); + + it('should handle URL object as input (without baseUrl)', async () => { + mockProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + mockFetch.mockResolvedValue(new Response('success', { status: 200 })); + + // Test URL object without baseUrl - should extract origin from URL object + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + await enhancedFetch(new URL('https://api.example.com/data')); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + }); + + it('should handle URL object in auth retry (without baseUrl)', async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: 'old-token', + token_type: 'Bearer', + expires_in: 3600 + }) + .mockResolvedValueOnce({ + access_token: 'new-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const unauthorizedResponse = new Response('Unauthorized', { status: 401 }); + const successResponse = new Response('success', { status: 200 }); + + mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse); + + mockExtractWWWAuthenticateParams.mockReturnValue({}); + mockAuth.mockResolvedValue('AUTHORIZED'); + + const enhancedFetch = withOAuth(mockProvider)(mockFetch); + + const result = await enhancedFetch(new URL('https://api.example.com/data')); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: 'https://api.example.com', // Should extract origin from URL object + resourceMetadataUrl: undefined, + fetchFn: mockFetch + }); + }); +}); + +describe('withLogging', () => { + let mockFetch: MockedFunction; + let mockLogger: MockedFunction< + (input: { + method: string; + url: string | URL; + status: number; + statusText: string; + duration: number; + requestHeaders?: Headers; + responseHeaders?: Headers; + error?: Error; + }) => void + >; + let consoleErrorSpy: MockInstance; + let consoleLogSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockFetch = vi.fn(); + mockLogger = vi.fn(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it('should log successful requests with default logger', async () => { + const response = new Response('success', { status: 200, statusText: 'OK' }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging()(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data 200 OK \(\d+\.\d+ms\)/) + ); + }); + + it('should log error responses with default logger', async () => { + const response = new Response('Not Found', { + status: 404, + statusText: 'Not Found' + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging()(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data 404 Not Found \(\d+\.\d+ms\)/) + ); + }); + + it('should log network errors with default logger', async () => { + const networkError = new Error('Network connection failed'); + mockFetch.mockRejectedValue(networkError); + + const enhancedFetch = withLogging()(mockFetch); + + await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Network connection failed'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringMatching(/HTTP GET https:\/\/api\.example\.com\/data failed: Network connection failed \(\d+\.\d+ms\)/) + ); + }); + + it('should use custom logger when provided', async () => { + const response = new Response('success', { status: 200, statusText: 'OK' }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch); + + await enhancedFetch('https://api.example.com/data', { method: 'POST' }); + + expect(mockLogger).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://api.example.com/data', + status: 200, + statusText: 'OK', + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined + }); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + + it('should include request headers when configured', async () => { + const response = new Response('success', { status: 200, statusText: 'OK' }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + logger: mockLogger, + includeRequestHeaders: true + })(mockFetch); + + await enhancedFetch('https://api.example.com/data', { + headers: { + Authorization: 'Bearer token', + 'Content-Type': 'application/json' + } + }); + + expect(mockLogger).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://api.example.com/data', + status: 200, + statusText: 'OK', + duration: expect.any(Number), + requestHeaders: expect.any(Headers), + responseHeaders: undefined + }); + + const logCall = mockLogger.mock.calls[0]![0]; + expect(logCall.requestHeaders?.get('Authorization')).toBe('Bearer token'); + expect(logCall.requestHeaders?.get('Content-Type')).toBe('application/json'); + }); + + it('should include response headers when configured', async () => { + const response = new Response('success', { + status: 200, + statusText: 'OK', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + } + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + logger: mockLogger, + includeResponseHeaders: true + })(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + const logCall = mockLogger.mock.calls[0]![0]; + expect(logCall.responseHeaders?.get('Content-Type')).toBe('application/json'); + expect(logCall.responseHeaders?.get('Cache-Control')).toBe('no-cache'); + }); + + it('should respect statusLevel option', async () => { + const successResponse = new Response('success', { + status: 200, + statusText: 'OK' + }); + const errorResponse = new Response('Server Error', { + status: 500, + statusText: 'Internal Server Error' + }); + + mockFetch.mockResolvedValueOnce(successResponse).mockResolvedValueOnce(errorResponse); + + const enhancedFetch = withLogging({ + logger: mockLogger, + statusLevel: 400 + })(mockFetch); + + // 200 response should not be logged (below statusLevel 400) + await enhancedFetch('https://api.example.com/success'); + expect(mockLogger).not.toHaveBeenCalled(); + + // 500 response should be logged (above statusLevel 400) + await enhancedFetch('https://api.example.com/error'); + expect(mockLogger).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://api.example.com/error', + status: 500, + statusText: 'Internal Server Error', + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined + }); + }); + + it('should always log network errors regardless of statusLevel', async () => { + const networkError = new Error('Connection timeout'); + mockFetch.mockRejectedValue(networkError); + + const enhancedFetch = withLogging({ + logger: mockLogger, + statusLevel: 500 // Very high log level + })(mockFetch); + + await expect(enhancedFetch('https://api.example.com/data')).rejects.toThrow('Connection timeout'); + + expect(mockLogger).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://api.example.com/data', + status: 0, + statusText: 'Network Error', + duration: expect.any(Number), + requestHeaders: undefined, + error: networkError + }); + }); + + it('should include headers in default logger message when configured', async () => { + const response = new Response('success', { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' } + }); + mockFetch.mockResolvedValue(response); + + const enhancedFetch = withLogging({ + includeRequestHeaders: true, + includeResponseHeaders: true + })(mockFetch); + + await enhancedFetch('https://api.example.com/data', { + headers: { Authorization: 'Bearer token' } + }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Request Headers: {authorization: Bearer token}')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Response Headers: {content-type: application/json}')); + }); + + it('should measure request duration accurately', async () => { + // Mock a slow response + const response = new Response('success', { status: 200 }); + mockFetch.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + return response; + }); + + const enhancedFetch = withLogging({ logger: mockLogger })(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + const logCall = mockLogger.mock.calls[0]![0]; + expect(logCall.duration).toBeGreaterThanOrEqual(90); // Allow some margin for timing + }); +}); + +describe('applyMiddleware', () => { + let mockFetch: MockedFunction; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + }); + + it('should compose no middleware correctly', () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + const composedFetch = applyMiddlewares()(mockFetch); + + expect(composedFetch).toBe(mockFetch); + }); + + it('should compose single middleware correctly', async () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create a middleware that adds a header + const middleware1 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('X-Middleware-1', 'applied'); + return next(input, { ...init, headers }); + }; + + const composedFetch = applyMiddlewares(middleware1)(mockFetch); + + await composedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('X-Middleware-1')).toBe('applied'); + }); + + it('should compose multiple middleware in order', async () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create middleware that add identifying headers + const middleware1 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('X-Middleware-1', 'applied'); + return next(input, { ...init, headers }); + }; + + const middleware2 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('X-Middleware-2', 'applied'); + return next(input, { ...init, headers }); + }; + + const middleware3 = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('X-Middleware-3', 'applied'); + return next(input, { ...init, headers }); + }; + + const composedFetch = applyMiddlewares(middleware1, middleware2, middleware3)(mockFetch); + + await composedFetch('https://api.example.com/data'); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('X-Middleware-1')).toBe('applied'); + expect(headers.get('X-Middleware-2')).toBe('applied'); + expect(headers.get('X-Middleware-3')).toBe('applied'); + }); + + it('should work with real fetch middleware functions', async () => { + const response = new Response('success', { status: 200, statusText: 'OK' }); + mockFetch.mockResolvedValue(response); + + // Create middleware that add identifying headers + const oauthMiddleware = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', 'Bearer test-token'); + return next(input, { ...init, headers }); + }; + + // Use custom logger to avoid console output + const mockLogger = vi.fn(); + const composedFetch = applyMiddlewares(oauthMiddleware, withLogging({ logger: mockLogger, statusLevel: 0 }))(mockFetch); + + await composedFetch('https://api.example.com/data'); + + // Should have both Authorization header and logging + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer test-token'); + expect(mockLogger).toHaveBeenCalledWith({ + method: 'GET', + url: 'https://api.example.com/data', + status: 200, + statusText: 'OK', + duration: expect.any(Number), + requestHeaders: undefined, + responseHeaders: undefined + }); + }); + + it('should preserve error propagation through middleware', async () => { + const errorMiddleware = (next: FetchLike) => async (input: string | URL, init?: RequestInit) => { + try { + return await next(input, init); + } catch (error) { + // Add context to the error + throw new Error(`Middleware error: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const originalError = new Error('Network failure'); + mockFetch.mockRejectedValue(originalError); + + const composedFetch = applyMiddlewares(errorMiddleware)(mockFetch); + + await expect(composedFetch('https://api.example.com/data')).rejects.toThrow('Middleware error: Network failure'); + }); +}); + +describe('Integration Tests', () => { + let mockProvider: Mocked; + let mockFetch: MockedFunction; + + beforeEach(() => { + vi.clearAllMocks(); + + mockProvider = { + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + tokens: vi.fn(), + saveTokens: vi.fn(), + clientInformation: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }; + + mockFetch = vi.fn(); + }); + + it('should work with SSE transport pattern', async () => { + // Simulate how SSE transport might use the middleware + mockProvider.tokens.mockResolvedValue({ + access_token: 'sse-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const response = new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + mockFetch.mockResolvedValue(response); + + // Use custom logger to avoid console output + const mockLogger = vi.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com'), + withLogging({ logger: mockLogger, statusLevel: 400 }) // Only log errors + )(mockFetch); + + // Simulate SSE POST request + await enhancedFetch('https://mcp-server.example.com/endpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1 + }) + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mcp-server.example.com/endpoint', + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: expect.any(String) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer sse-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + }); + + it('should work with StreamableHTTP transport pattern', async () => { + // Simulate how StreamableHTTP transport might use the middleware + mockProvider.tokens.mockResolvedValue({ + access_token: 'streamable-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const response = new Response(null, { + status: 202, + headers: { 'mcp-session-id': 'session-123' } + }); + mockFetch.mockResolvedValue(response); + + // Use custom logger to avoid console output + const mockLogger = vi.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth(mockProvider as OAuthClientProvider, 'https://streamable-server.example.com'), + withLogging({ + logger: mockLogger, + includeResponseHeaders: true, + statusLevel: 0 + }) + )(mockFetch); + + // Simulate StreamableHTTP initialization request + await enhancedFetch('https://streamable-server.example.com/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { protocolVersion: '2025-03-26', clientInfo: { name: 'test' } }, + id: 1 + }) + }); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Bearer streamable-token'); + expect(headers.get('Accept')).toBe('application/json, text/event-stream'); + }); + + it('should handle auth retry in transport-like scenario', async () => { + mockProvider.tokens + .mockResolvedValueOnce({ + access_token: 'expired-token', + token_type: 'Bearer', + expires_in: 3600 + }) + .mockResolvedValueOnce({ + access_token: 'fresh-token', + token_type: 'Bearer', + expires_in: 3600 + }); + + const unauthorizedResponse = new Response('{"error":"invalid_token"}', { + status: 401, + headers: { 'www-authenticate': 'Bearer realm="mcp"' } + }); + const successResponse = new Response('{"jsonrpc":"2.0","id":1,"result":{}}', { + status: 200 + }); + + mockFetch.mockResolvedValueOnce(unauthorizedResponse).mockResolvedValueOnce(successResponse); + + mockExtractWWWAuthenticateParams.mockReturnValue({ + resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'), + scope: 'read' + }); + mockAuth.mockResolvedValue('AUTHORIZED'); + + // Use custom logger to avoid console output + const mockLogger = vi.fn(); + const enhancedFetch = applyMiddlewares( + withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com'), + withLogging({ logger: mockLogger, statusLevel: 0 }) + )(mockFetch); + + const result = await enhancedFetch('https://mcp-server.example.com/endpoint', { + method: 'POST', + body: JSON.stringify({ jsonrpc: '2.0', method: 'test', id: 1 }) + }); + + expect(result).toBe(successResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockAuth).toHaveBeenCalledWith(mockProvider, { + serverUrl: 'https://mcp-server.example.com', + resourceMetadataUrl: new URL('https://auth.example.com/.well-known/oauth-protected-resource'), + scope: 'read', + fetchFn: mockFetch + }); + }); +}); + +describe('createMiddleware', () => { + let mockFetch: MockedFunction; + + beforeEach(() => { + vi.clearAllMocks(); + mockFetch = vi.fn(); + }); + + it('should create middleware with cleaner syntax', async () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + const customMiddleware = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('X-Custom-Header', 'custom-value'); + return next(input, { ...init, headers }); + }); + + const enhancedFetch = customMiddleware(mockFetch); + await enhancedFetch('https://api.example.com/data'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.any(Headers) + }) + ); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('X-Custom-Header')).toBe('custom-value'); + }); + + it('should support conditional middleware logic', async () => { + const apiResponse = new Response('api response', { status: 200 }); + const publicResponse = new Response('public response', { status: 200 }); + mockFetch.mockResolvedValueOnce(apiResponse).mockResolvedValueOnce(publicResponse); + + const conditionalMiddleware = createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/api/')) { + const headers = new Headers(init?.headers); + headers.set('X-API-Version', 'v2'); + return next(input, { ...init, headers }); + } + + return next(input, init); + }); + + const enhancedFetch = conditionalMiddleware(mockFetch); + + // Test API route + await enhancedFetch('https://example.com/api/users'); + let callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('X-API-Version')).toBe('v2'); + + // Test non-API route + await enhancedFetch('https://example.com/public/page'); + callArgs = mockFetch.mock.calls[1]; + const maybeHeaders = callArgs![1]?.headers as Headers | undefined; + expect(maybeHeaders?.get('X-API-Version')).toBeUndefined(); + }); + + it('should support short-circuit responses', async () => { + const customMiddleware = createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + + // Short-circuit for specific URL + if (url.includes('/cached')) { + return new Response('cached data', { status: 200 }); + } + + return next(input, init); + }); + + const enhancedFetch = customMiddleware(mockFetch); + + // Test cached route (should not call mockFetch) + const cachedResponse = await enhancedFetch('https://example.com/cached/data'); + expect(await cachedResponse.text()).toBe('cached data'); + expect(mockFetch).not.toHaveBeenCalled(); + + // Test normal route + mockFetch.mockResolvedValue(new Response('fresh data', { status: 200 })); + const normalResponse = await enhancedFetch('https://example.com/normal/data'); + expect(await normalResponse.text()).toBe('fresh data'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should handle response transformation', async () => { + const originalResponse = new Response('{"data": "original"}', { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + mockFetch.mockResolvedValue(originalResponse); + + const transformMiddleware = createMiddleware(async (next, input, init) => { + const response = await next(input, init); + + if (response.headers.get('content-type')?.includes('application/json')) { + const data = (await response.json()) as Record; + const transformed = { ...data, timestamp: 123_456_789 }; + + return Response.json(transformed, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } + + return response; + }); + + const enhancedFetch = transformMiddleware(mockFetch); + const response = await enhancedFetch('https://api.example.com/data'); + const result = await response.json(); + + expect(result).toEqual({ + data: 'original', + timestamp: 123_456_789 + }); + }); + + it('should support error handling and recovery', async () => { + let attemptCount = 0; + mockFetch.mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) { + throw new Error('Network error'); + } + return new Response('success', { status: 200 }); + }); + + const retryMiddleware = createMiddleware(async (next, input, init) => { + try { + return await next(input, init); + } catch (error) { + // Retry once on network error + console.log('Retrying request after error:', error); + return await next(input, init); + } + }); + + const enhancedFetch = retryMiddleware(mockFetch); + const response = await enhancedFetch('https://api.example.com/data'); + + expect(await response.text()).toBe('success'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('should compose well with other middleware', async () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + // Create custom middleware using createMiddleware + const customAuth = createMiddleware(async (next, input, init) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', 'Custom token'); + return next(input, { ...init, headers }); + }); + + const customLogging = createMiddleware(async (next, input, init) => { + const url = typeof input === 'string' ? input : input.toString(); + console.log(`Request to: ${url}`); + const response = await next(input, init); + console.log(`Response status: ${response.status}`); + return response; + }); + + // Compose with existing middleware + const enhancedFetch = applyMiddlewares(customAuth, customLogging, withLogging({ statusLevel: 400 }))(mockFetch); + + await enhancedFetch('https://api.example.com/data'); + + const callArgs = mockFetch.mock.calls[0]; + const headers = callArgs![1]?.headers as Headers; + expect(headers.get('Authorization')).toBe('Custom token'); + }); + + it('should have access to both input types (string and URL)', async () => { + const response = new Response('success', { status: 200 }); + mockFetch.mockResolvedValue(response); + + let capturedInputType: string | undefined; + const inspectMiddleware = createMiddleware(async (next, input, init) => { + capturedInputType = typeof input === 'string' ? 'string' : 'URL'; + return next(input, init); + }); + + const enhancedFetch = inspectMiddleware(mockFetch); + + // Test with string input + await enhancedFetch('https://api.example.com/data'); + expect(capturedInputType).toBe('string'); + + // Test with URL input + await enhancedFetch(new URL('https://api.example.com/data')); + expect(capturedInputType).toBe('URL'); + }); +}); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts new file mode 100644 index 0000000..6948d9a --- /dev/null +++ b/packages/client/test/client/sse.test.ts @@ -0,0 +1,1700 @@ +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { JSONRPCMessage, OAuthTokens } from '@modelcontextprotocol/core'; +import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import type { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; + +import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth.js'; +import { UnauthorizedError } from '../../src/client/auth.js'; +import { SSEClientTransport } from '../../src/client/sse.js'; + +/** + * Parses HTTP Basic auth from a request's Authorization header. + * Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed. + * client_secret_basic is the default client auth method when server metadata omits + * token_endpoint_auth_methods_supported (RFC 8414 §2). + */ +function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Basic ')) return undefined; + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + if (sep === -1) return undefined; + return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) }; +} + +describe('SSEClientTransport', () => { + let resourceServer: Server; + let authServer: Server; + let transport: SSEClientTransport; + let resourceBaseUrl: URL; + let authBaseUrl: URL; + let lastServerRequest: IncomingMessage; + let sendServerMessage: ((message: string) => void) | null = null; + + beforeEach(async () => { + // Reset state + lastServerRequest = null as unknown as IncomingMessage; + sendServerMessage = null; + + authServer = createServer((req, res) => { + if (req.url === '/.well-known/oauth-authorization-server') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }); + res.end( + JSON.stringify({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + ); + return; + } + res.writeHead(401).end(); + }); + + // Create a test server that will receive the EventSource connection + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + // Send SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + + // Send the endpoint event + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + + // Store reference to send function for tests + sendServerMessage = (message: string) => { + res.write(`data: ${message}\n\n`); + }; + + // Handle request body for POST endpoints + if (req.method === 'POST') { + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + (req as IncomingMessage & { body: string }).body = body; + res.end(); + }); + } + }); + + // Start server on random port + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + await transport.close(); + await resourceServer.close(); + await authServer.close(); + + vi.clearAllMocks(); + }); + + describe('connection handling', () => { + it('establishes SSE connection and receives endpoint', async () => { + transport = new SSEClientTransport(resourceBaseUrl); + await transport.start(); + + expect(lastServerRequest.headers.accept).toBe('text/event-stream'); + expect(lastServerRequest.method).toBe('GET'); + }); + + it('rejects if server returns non-200 status', async () => { + // Create a server that returns 403 + await resourceServer.close(); + + resourceServer = createServer((_req, res) => { + res.writeHead(403); + res.end(); + }); + + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + transport = new SSEClientTransport(resourceBaseUrl); + await expect(transport.start()).rejects.toThrow(); + }); + + it('closes EventSource connection on close()', async () => { + transport = new SSEClientTransport(resourceBaseUrl); + await transport.start(); + + const closePromise = new Promise(resolve => { + lastServerRequest.on('close', resolve); + }); + + await transport.close(); + await closePromise; + }); + }); + + describe('message handling', () => { + it('receives and parses JSON-RPC messages', async () => { + const receivedMessages: JSONRPCMessage[] = []; + transport = new SSEClientTransport(resourceBaseUrl); + transport.onmessage = msg => receivedMessages.push(msg); + + await transport.start(); + + const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'test-1', + method: 'test', + params: { foo: 'bar' } + }; + + sendServerMessage!(JSON.stringify(testMessage)); + + // Wait for message processing + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(receivedMessages).toHaveLength(1); + expect(receivedMessages[0]).toEqual(testMessage); + }); + + it('handles malformed JSON messages', async () => { + const errors: Error[] = []; + transport = new SSEClientTransport(resourceBaseUrl); + transport.onerror = err => errors.push(err); + + await transport.start(); + + sendServerMessage!('invalid json'); + + // Wait for message processing + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toMatch(/JSON/); + }); + + it('handles messages via POST requests', async () => { + transport = new SSEClientTransport(resourceBaseUrl); + await transport.start(); + + const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'test-1', + method: 'test', + params: { foo: 'bar' } + }; + + await transport.send(testMessage); + + // Wait for request processing + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(lastServerRequest.method).toBe('POST'); + expect(lastServerRequest.headers['content-type']).toBe('application/json'); + expect(JSON.parse((lastServerRequest as IncomingMessage & { body: string }).body)).toEqual(testMessage); + }); + + it('handles POST request failures', async () => { + // Create a server that returns 500 for POST + await resourceServer.close(); + + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + } else { + res.writeHead(500); + res.end('Internal error'); + } + }); + + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + transport = new SSEClientTransport(resourceBaseUrl); + await transport.start(); + + const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + id: 'test-1', + method: 'test', + params: {} + }; + + await expect(transport.send(testMessage)).rejects.toThrow(/500/); + }); + }); + + describe('header handling', () => { + it('uses custom fetch implementation from EventSourceInit to add auth headers', async () => { + const authToken = 'Bearer test-token'; + + // Create a fetch wrapper that adds auth header + const fetchWithAuth = (url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', authToken); + return fetch(url.toString(), { ...init, headers }); + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + eventSourceInit: { + fetch: fetchWithAuth + } + }); + + await transport.start(); + + // Verify the auth header was received by the server + expect(lastServerRequest.headers.authorization).toBe(authToken); + }); + + it('uses custom fetch implementation from options', async () => { + const authToken = 'Bearer custom-token'; + + const fetchWithAuth = vi.fn((url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + headers.set('Authorization', authToken); + return fetch(url.toString(), { ...init, headers }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + fetch: fetchWithAuth + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe(authToken); + + // Send a message to verify fetchWithAuth used for POST as well + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + expect(fetchWithAuth).toHaveBeenCalledTimes(2); + expect(lastServerRequest.method).toBe('POST'); + expect(lastServerRequest.headers.authorization).toBe(authToken); + }); + + it('passes custom headers to fetch requests', async () => { + const customHeaders = { + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'custom-value' + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { + headers: customHeaders + } + }); + + await transport.start(); + + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); + expect(calledHeaders.get('content-type')).toBe('application/json'); + + customHeaders['X-Custom-Header'] = 'updated-value'; + + await transport.send(message); + + const updatedHeaders = (globalThis.fetch as Mock).mock.calls[1]![1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('passes custom headers to fetch requests (Headers class)', async () => { + const customHeaders = new Headers({ + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'custom-value' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { + headers: customHeaders + } + }); + + await transport.start(); + + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); + expect(calledHeaders.get('content-type')).toBe('application/json'); + + customHeaders.set('X-Custom-Header', 'updated-value'); + + await transport.send(message); + + const updatedHeaders = (globalThis.fetch as Mock).mock.calls[1]![1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('passes custom headers to fetch requests (array of tuples)', async () => { + transport = new SSEClientTransport(resourceBaseUrl, { + requestInit: { + headers: [ + ['Authorization', 'Bearer test-token'], + ['X-Custom-Header', 'custom-value'] + ] + } + }); + + await transport.start(); + + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: true }); + + await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); + + const calledHeaders = (globalThis.fetch as Mock).mock.calls[0]![1].headers; + expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); + expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); + expect(calledHeaders.get('content-type')).toBe('application/json'); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); + + describe('auth handling', () => { + const authServerMetadataUrls = new Set(['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']); + + let mockAuthProvider: Mocked; + + beforeEach(() => { + mockAuthProvider = { + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }; + }); + + it('attaches auth header from provider on SSE connection', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); + expect(mockAuthProvider.tokens).toHaveBeenCalled(); + }); + + it('attaches custom header from provider on initial SSE connection', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + const customHeaders = { + 'X-Custom-Header': 'custom-value' + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + requestInit: { + headers: customHeaders + } + }); + + await transport.start(); + + expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); + expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value'); + expect(mockAuthProvider.tokens).toHaveBeenCalled(); + }); + + it('attaches auth header from provider on POST requests', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); + expect(mockAuthProvider.tokens).toHaveBeenCalled(); + }); + + it('attempts auth flow on 401 during SSE connection', async () => { + // Create server that returns 401s + resourceServer.close(); + authServer.close(); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }).end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`] + }) + ); + return; + } + + if (req.url === '/') { + res.writeHead(401).end(); + } else { + res.writeHead(404).end(); + } + }); + + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await expect(() => transport.start()).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); + }); + + it('attempts auth flow on 401 during POST request', async () => { + // Create server that accepts SSE but returns 401 on POST + resourceServer.close(); + authServer.close(); + + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + switch (req.method) { + case 'GET': { + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }).end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`] + }) + ); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + break; + } + + case 'POST': { + res.writeHead(401); + res.end(); + break; + } + } + }); + + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await expect(() => transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); + }); + + it('respects custom headers when using auth provider', async () => { + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer' + }); + + const customHeaders = { + 'X-Custom-Header': 'custom-value' + }; + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + requestInit: { + headers: customHeaders + } + }); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); + expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value'); + }); + + it('refreshes expired token during SSE connection', async () => { + // Mock tokens() to return expired token until saveTokens is called + let currentTokens: OAuthTokens = { + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }; + mockAuthProvider.tokens.mockImplementation(() => currentTokens); + mockAuthProvider.saveTokens.mockImplementation(tokens => { + currentTokens = tokens; + }); + + // Create server that returns 401 for expired token, then accepts new token + resourceServer.close(); + authServer.close(); + + authServer = createServer((req, res) => { + if (req.url && authServerMetadataUrls.has(req.url)) { + res.writeHead(404).end(); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + // Handle token refresh request + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); + if ( + params.get('grant_type') === 'refresh_token' && + params.get('refresh_token') === 'refresh-token' && + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' + ) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }) + ); + } else { + res.writeHead(400).end(); + } + }); + return; + } + + res.writeHead(401).end(); + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let connectionAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }).end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`] + }) + ); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + + const auth = req.headers.authorization; + if (auth === 'Bearer expired-token') { + res.writeHead(401).end(); + return; + } + + if (auth === 'Bearer new-token') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + connectionAttempts++; + return; + } + + res.writeHead(401).end(); + }); + + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await transport.start(); + + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }); + expect(connectionAttempts).toBe(1); + expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); + }); + + it('refreshes expired token during POST request', async () => { + // Mock tokens() to return expired token until saveTokens is called + let currentTokens: OAuthTokens = { + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }; + mockAuthProvider.tokens.mockImplementation(() => currentTokens); + mockAuthProvider.saveTokens.mockImplementation(tokens => { + currentTokens = tokens; + }); + + // Create server that returns 401 for expired token, then accepts new token + resourceServer.close(); + authServer.close(); + + authServer = createServer((req, res) => { + if (req.url && authServerMetadataUrls.has(req.url)) { + res.writeHead(404).end(); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + // Handle token refresh request + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); + if ( + params.get('grant_type') === 'refresh_token' && + params.get('refresh_token') === 'refresh-token' && + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' + ) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }) + ); + } else { + res.writeHead(400).end(); + } + }); + return; + } + + res.writeHead(401).end(); + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + let postAttempts = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }).end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`] + }) + ); + return; + } + + switch (req.method) { + case 'GET': { + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + break; + } + + case 'POST': { + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + + const auth = req.headers.authorization; + if (auth === 'Bearer expired-token') { + res.writeHead(401).end(); + return; + } + + if (auth === 'Bearer new-token') { + res.writeHead(200).end(); + postAttempts++; + return; + } + + res.writeHead(401).end(); + break; + } + } + }); + + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await transport.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token' + }); + expect(postAttempts).toBe(1); + expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); + }); + + it('redirects to authorization if refresh token flow fails', async () => { + // Mock tokens() to return expired token until saveTokens is called + let currentTokens: OAuthTokens = { + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }; + mockAuthProvider.tokens.mockImplementation(() => currentTokens); + mockAuthProvider.saveTokens.mockImplementation(tokens => { + currentTokens = tokens; + }); + + // Create server that returns 401 for all tokens + resourceServer.close(); + authServer.close(); + + authServer = createServer((req, res) => { + if (req.url && authServerMetadataUrls.has(req.url)) { + res.writeHead(404).end(); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + // Handle token refresh request - always fail + res.writeHead(400).end(); + return; + } + + res.writeHead(401).end(); + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { + 'Content-Type': 'application/json' + }).end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [`${authBaseUrl}`] + }) + ); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider + }); + + await expect(() => transport.start()).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + }); + + it('invalidates all credentials on OAuthErrorCode.InvalidClient during token refresh', async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }); + + const expectedError = new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed'); + let baseUrl = resourceBaseUrl; + + // Create server that returns OAuthErrorCode.InvalidClient on token refresh + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + ); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject())); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + baseUrl = await listenOnRandomPort(server); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider + }); + + await expect(() => transport.start()).rejects.toMatchObject(expectedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during token refresh', async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }); + + const expectedError = new OAuthError(OAuthErrorCode.UnauthorizedClient, 'Client not authorized'); + let baseUrl = resourceBaseUrl; + + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + ); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject())); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + baseUrl = await listenOnRandomPort(server); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider + }); + + await expect(() => transport.start()).rejects.toMatchObject(expectedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it('invalidates tokens on OAuthErrorCode.InvalidGrant during token refresh', async () => { + // Mock tokens() to return token with refresh token + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'expired-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }); + + const expectedError = new OAuthError(OAuthErrorCode.InvalidGrant, 'Invalid refresh token'); + let baseUrl = resourceBaseUrl; + + const server = createServer((req, res) => { + lastServerRequest = req; + + // Handle OAuth metadata discovery + if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: baseUrl.href, + authorization_endpoint: `${baseUrl.href}authorize`, + token_endpoint: `${baseUrl.href}token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + ); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(expectedError.toResponseObject())); + return; + } + + if (req.url !== '/') { + res.writeHead(404).end(); + return; + } + res.writeHead(401).end(); + }); + + baseUrl = await listenOnRandomPort(server); + + transport = new SSEClientTransport(baseUrl, { + authProvider: mockAuthProvider + }); + + await expect(() => transport.start()).rejects.toMatchObject(expectedError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); + }); + }); + + describe('custom fetch in auth code paths', () => { + let customFetch: MockedFunction; + let globalFetchSpy: MockInstance; + let mockAuthProvider: Mocked; + let resourceServerHandler: Mock; + + /** + * Helper function to create a mock auth provider with configurable behavior + */ + const createMockAuthProvider = ( + config: { + hasTokens?: boolean; + tokensExpired?: boolean; + hasRefreshToken?: boolean; + clientRegistered?: boolean; + authorizationCode?: string; + } = {} + ): Mocked => { + const tokens = config.hasTokens + ? { + access_token: config.tokensExpired ? 'expired-token' : 'valid-token', + token_type: 'Bearer' as const, + ...(config.hasRefreshToken && { refresh_token: 'refresh-token' }) + } + : undefined; + + const clientInfo = config.clientRegistered + ? { + client_id: 'test-client-id', + client_secret: 'test-client-secret' + } + : undefined; + + return { + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue(clientInfo), + tokens: vi.fn().mockResolvedValue(tokens), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test-verifier'), + invalidateCredentials: vi.fn() + }; + }; + + const createCustomFetchMockAuthServer = async () => { + authServer = createServer((req, res) => { + if (req.url === '/.well-known/oauth-authorization-server') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + issuer: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}`, + authorization_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/authorize`, + token_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/token`, + registration_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/register`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + ); + return; + } + + if (req.url === '/token' && req.method === 'POST') { + // Handle token exchange request + let body = ''; + req.on('data', chunk => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + const basicAuth = parseBasicAuth(req); + if ( + params.get('grant_type') === 'authorization_code' && + params.get('code') === 'test-auth-code' && + basicAuth?.clientId === 'test-client-id' && + basicAuth?.clientSecret === 'test-client-secret' + ) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + ); + } else { + res.writeHead(400).end(); + } + }); + return; + } + + res.writeHead(404).end(); + }); + + // Start auth server on random port + await new Promise(resolve => { + authServer.listen(0, '127.0.0.1', () => { + const addr = authServer.address() as AddressInfo; + authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + }; + + const createCustomFetchMockResourceServer = async () => { + // Set up resource server that provides OAuth metadata + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.url === '/.well-known/oauth-protected-resource') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + resource: resourceBaseUrl.href, + authorization_servers: [authBaseUrl.href] + }) + ); + return; + } + + resourceServerHandler(req, res); + }); + + // Start resource server on random port + await new Promise(resolve => { + resourceServer.listen(0, '127.0.0.1', () => { + const addr = resourceServer.address() as AddressInfo; + resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); + resolve(); + }); + }); + }; + + beforeEach(async () => { + // Close existing servers to set up custom auth flow servers + resourceServer.close(); + authServer.close(); + + const originalFetch = fetch; + + // Create custom fetch spy that delegates to real fetch + customFetch = vi.fn((url, init) => { + return originalFetch(url.toString(), init); + }); + + // Spy on global fetch to detect unauthorized usage + globalFetchSpy = vi.spyOn(globalThis, 'fetch'); + + // Create mock auth provider with default configuration + mockAuthProvider = createMockAuthProvider({ + hasTokens: false, + clientRegistered: true + }); + + // Set up auth server that handles OAuth discovery and token requests + await createCustomFetchMockAuthServer(); + + // Set up resource server + resourceServerHandler = vi.fn( + ( + _req: IncomingMessage, + res: ServerResponse & { + req: IncomingMessage; + } + ) => { + res.writeHead(404).end(); + } + ); + await createCustomFetchMockResourceServer(); + }); + + afterEach(() => { + globalFetchSpy.mockRestore(); + }); + + it('uses custom fetch during auth flow on SSE connection 401 - no global fetch fallback', async () => { + // Set up resource server that returns 401 on SSE connection and provides OAuth metadata + resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { + if (req.url === '/') { + // Return 401 to trigger auth flow + res.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` + }); + res.end(); + return; + } + + res.writeHead(404).end(); + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError + await expect(transport.start()).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + + it('uses custom fetch during auth flow on POST request 401 - no global fetch fallback', async () => { + // Set up resource server that accepts SSE connection but returns 401 on POST + resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { + switch (req.method) { + case 'GET': { + if (req.url === '/') { + // Accept SSE connection + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}\n\n`); + return; + } + break; + } + + case 'POST': { + if (req.url === '/') { + // Return 401 to trigger auth retry + res.writeHead(401, { + 'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` + }); + res.end(); + return; + } + break; + } + } + + res.writeHead(404).end(); + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Start the transport (should succeed) + await transport.start(); + + // Send a message that should trigger 401 and auth retry + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + // Attempt to send message - should trigger auth flow and eventually fail + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have attempted the POST request that triggered the 401 + const postCalls = customFetchCalls.filter( + ([url, options]) => url.toString() === resourceBaseUrl.href && options?.method === 'POST' + ); + expect(postCalls.length).toBeGreaterThan(0); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + + it('uses custom fetch in finishAuth method - no global fetch fallback', async () => { + // Create mock auth provider that expects to save tokens + const authProviderWithCode = createMockAuthProvider({ + clientRegistered: true, + authorizationCode: 'test-auth-code' + }); + + // Create transport with custom fetch and auth provider + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: authProviderWithCode, + fetch: customFetch + }); + + // Call finishAuth with authorization code + await transport.finishAuth('test-auth-code'); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have called token endpoint for authorization code exchange + const tokenCalls = customFetchCalls.filter(([url, options]) => url.toString().includes('/token') && options?.method === 'POST'); + expect(tokenCalls.length).toBeGreaterThan(0); + + // Verify tokens were saved + expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }); + + // Global fetch should never have been called + expect(globalFetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('minimal AuthProvider (non-OAuth)', () => { + let postResponses: number[]; + let postCount: number; + + async function setupServer(): Promise { + await resourceServer.close(); + + postCount = 0; + resourceServer = createServer((req, res) => { + lastServerRequest = req; + + if (req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}post\n\n`); + return; + } + + if (req.method === 'POST') { + const status = postResponses[postCount] ?? 200; + postCount++; + res.writeHead(status).end(); + return; + } + }); + + resourceBaseUrl = await listenOnRandomPort(resourceServer); + } + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: '1' }; + + it('throws UnauthorizedError on POST 401 when onUnauthorized is not provided', async () => { + postResponses = [401]; + await setupServer(); + + const authProvider: AuthProvider = { token: async () => 'api-key' }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + await transport.start(); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + }); + + it('enforces circuit breaker on double-401: onUnauthorized called once, then throws SdkHttpError', async () => { + postResponses = [401, 401]; + await setupServer(); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'still-bad'), + onUnauthorized: vi.fn(async () => {}) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + await transport.start(); + + const error = await transport.send(message).catch(e => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(postCount).toBe(2); + }); + + it('resets retry guard when onUnauthorized throws, allowing retry on next send', async () => { + postResponses = [401, 401, 200]; + await setupServer(); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn().mockRejectedValueOnce(new Error('transient network error')).mockResolvedValueOnce(undefined) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + await transport.start(); + + // First send: 401 → onUnauthorized throws transient error + await expect(transport.send(message)).rejects.toThrow('transient network error'); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + + // Second send: flag should be reset, so 401 → onUnauthorized (succeeds) → retry → 200 + await transport.send(message); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(postCount).toBe(3); + }); + + it('throws when finishAuth is called with a non-OAuth AuthProvider', async () => { + postResponses = []; + await setupServer(); + + const authProvider: AuthProvider = { token: async () => 'api-key' }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + await transport.start(); + + await expect(transport.finishAuth('auth-code')).rejects.toThrow('finishAuth requires an OAuthClientProvider'); + }); + + it('SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt', async () => { + // Regression: _startOrAuth(true) baked isAuthRetry=true into the retry EventSource's + // onerror closure, so a subsequent 401 (token expiry on reconnect) would throw + // instead of refreshing. Fix: retry always calls _startOrAuth() fresh. + await resourceServer.close(); + + let getAttempt = 0; + resourceServer = createServer((req, res) => { + if (req.method !== 'GET') { + res.writeHead(404).end(); + return; + } + getAttempt++; + if (getAttempt < 3) { + res.writeHead(401).end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}post\n\n`); + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn(async () => {}) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + + await transport.start(); // should resolve on attempt 3 + + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(getAttempt).toBe(3); + }); + + it('retry failure during SSE connect fires onerror exactly once', async () => { + // Regression: when the retry EventSource rejected, its onerror fired inside, then + // the outer .then() rejection handler fired onerror AGAIN for the same error. + // Fix: inner retry chains to .then(resolve, reject) — no outer onerror call. + // onUnauthorized's own failure is handled separately and fires onerror once. + await resourceServer.close(); + + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(401).end(); // always 401 + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + const onUnauthorized: AuthProvider['onUnauthorized'] = vi + .fn() + .mockResolvedValueOnce(undefined) // first call succeeds → triggers retry + .mockRejectedValueOnce(new Error('refresh failed')); // second call (in retry) throws + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + await expect(transport.start()).rejects.toThrow('refresh failed'); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror.mock.calls[0]![0].message).toBe('refresh failed'); + }); + }); +}); diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts new file mode 100644 index 0000000..28a7834 --- /dev/null +++ b/packages/client/test/client/stdio.test.ts @@ -0,0 +1,79 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; + +import type { StdioServerParameters } from '../../src/client/stdio.js'; +import { StdioClientTransport } from '../../src/client/stdio.js'; + +// Configure default server parameters based on OS +// Uses 'more' command for Windows and 'tee' command for Unix/Linux +const getDefaultServerParameters = (): StdioServerParameters => { + if (process.platform === 'win32') { + return { command: 'more' }; + } + return { command: '/usr/bin/tee' }; +}; + +const serverParameters = getDefaultServerParameters(); + +test('should start then close cleanly', async () => { + const client = new StdioClientTransport(serverParameters); + client.onerror = error => { + throw error; + }; + + let didClose = false; + client.onclose = () => { + didClose = true; + }; + + await client.start(); + expect(didClose).toBeFalsy(); + await client.close(); + expect(didClose).toBeTruthy(); +}); + +test('should read messages', async () => { + const client = new StdioClientTransport(serverParameters); + client.onerror = error => { + throw error; + }; + + const messages: JSONRPCMessage[] = [ + { + jsonrpc: '2.0', + id: 1, + method: 'ping' + }, + { + jsonrpc: '2.0', + method: 'notifications/initialized' + } + ]; + + const readMessages: JSONRPCMessage[] = []; + const finished = new Promise(resolve => { + client.onmessage = message => { + readMessages.push(message); + + if (JSON.stringify(message) === JSON.stringify(messages[1])) { + resolve(); + } + }; + }); + + await client.start(); + await client.send(messages[0]!); + await client.send(messages[1]!); + await finished; + expect(readMessages).toEqual(messages); + + await client.close(); +}); + +test('should return child process pid', async () => { + const client = new StdioClientTransport(serverParameters); + + await client.start(); + expect(client.pid).not.toBeNull(); + await client.close(); + expect(client.pid).toBeNull(); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts new file mode 100644 index 0000000..0edf8b7 --- /dev/null +++ b/packages/client/test/client/streamableHttp.test.ts @@ -0,0 +1,2022 @@ +import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import { OAuthError, OAuthErrorCode, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; +import type { Mock, Mocked } from 'vitest'; + +import type { OAuthClientProvider } from '../../src/client/auth.js'; +import { UnauthorizedError } from '../../src/client/auth.js'; +import type { ReconnectionScheduler, StartSSEOptions, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +describe('StreamableHTTPClientTransport', () => { + let transport: StreamableHTTPClientTransport; + let mockAuthProvider: Mocked; + + beforeEach(() => { + mockAuthProvider = { + get redirectUrl() { + return 'http://localhost/callback'; + }, + get clientMetadata() { + return { redirect_uris: ['http://localhost/callback'] }; + }, + clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider: mockAuthProvider }); + vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(async () => { + await transport.close().catch(() => {}); + vi.clearAllMocks(); + }); + + it('should send JSON-RPC messages via POST', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send(message); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: JSON.stringify(message) + }) + ); + }); + + it('should send batch messages', async () => { + const messages: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'test1', params: {}, id: 'id1' }, + { jsonrpc: '2.0', method: 'test2', params: {}, id: 'id2' } + ]; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: null + }); + + await transport.send(messages); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + headers: expect.any(Headers), + body: JSON.stringify(messages) + }) + ); + }); + + it('should store session ID received during initialization', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + + await transport.send(message); + + // Send a second message that should include the session ID + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + + // Check that second request included session ID header + const calls = (globalThis.fetch as Mock).mock.calls; + const lastCall = calls.at(-1)!; + expect(lastCall[1].headers).toBeDefined(); + expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); + }); + + it('should accept protocolVersion constructor option and include it in request headers', async () => { + // When reconnecting with a preserved sessionId, users need to also preserve the + // negotiated protocol version so the required mcp-protocol-version header is sent. + const reconnectTransport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + sessionId: 'preserved-session-id', + protocolVersion: '2025-11-25' + }); + + expect(reconnectTransport.sessionId).toBe('preserved-session-id'); + expect(reconnectTransport.protocolVersion).toBe('2025-11-25'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await reconnectTransport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + + const calls = (globalThis.fetch as Mock).mock.calls; + const lastCall = calls.at(-1)!; + expect(lastCall[1].headers.get('mcp-session-id')).toBe('preserved-session-id'); + expect(lastCall[1].headers.get('mcp-protocol-version')).toBe('2025-11-25'); + + await reconnectTransport.close().catch(() => {}); + }); + + it('should terminate session with DELETE request', async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + + await transport.send(message); + expect(transport.sessionId).toBe('test-session-id'); + + // Now terminate the session + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers() + }); + + await transport.terminateSession(); + + // Verify the DELETE request was sent with the session ID + const calls = (globalThis.fetch as Mock).mock.calls; + const lastCall = calls.at(-1)!; + expect(lastCall[1].method).toBe('DELETE'); + expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); + + // The session ID should be cleared after successful termination + expect(transport.sessionId).toBeUndefined(); + }); + + it("should handle 405 response when server doesn't support session termination", async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + + await transport.send(message); + + // Now terminate the session, but server responds with 405 + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: 'Method Not Allowed', + headers: new Headers() + }); + + await expect(transport.terminateSession()).resolves.not.toThrow(); + }); + + it('should handle 404 response when session expires', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Session not found'), + headers: new Headers() + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + await expect(transport.send(message)).rejects.toThrow( + new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: Session not found', { + status: 404, + statusText: 'Not Found', + text: 'Session not found' + }) + ); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('should handle non-streaming JSON response', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const responseMessage: JSONRPCMessage = { + jsonrpc: '2.0', + result: { success: true }, + id: 'test-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(responseMessage) + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await transport.send(message); + + expect(messageSpy).toHaveBeenCalledWith(responseMessage); + }); + + it('should attempt initial GET connection and handle 405 gracefully', async () => { + // Mock the server not supporting GET for SSE (returning 405) + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 405, + statusText: 'Method Not Allowed' + }); + + // We expect the 405 error to be caught and handled gracefully + // This should not throw an error that breaks the transport + await transport.start(); + await expect(transport['_startOrAuthSse']({})).resolves.not.toThrow('Failed to open SSE stream: Method Not Allowed'); + // Check that GET was attempted + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers) + }) + ); + + // Verify transport still works after 405 + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('should handle successful initial GET connection for SSE', async () => { + // Set up readable stream for SSE events + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send a server notification via SSE + const event = 'event: message\ndata: {"jsonrpc": "2.0", "method": "serverNotification", "params": {}}\n\n'; + controller.enqueue(encoder.encode(event)); + } + }); + + // Mock successful GET connection + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await transport.start(); + await transport['_startOrAuthSse']({}); + + // Give time for the SSE event to be processed + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + method: 'serverNotification', + params: {} + }) + ); + }); + + it('should handle multiple concurrent SSE streams', async () => { + // Mock two POST requests that return SSE streams + const makeStream = (id: string) => { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + const event = `event: message\ndata: {"jsonrpc": "2.0", "result": {"id": "${id}"}, "id": "${id}"}\n\n`; + controller.enqueue(encoder.encode(event)); + } + }); + }; + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: makeStream('request1') + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: makeStream('request2') + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + // Send two concurrent requests + await Promise.all([ + transport.send({ jsonrpc: '2.0', method: 'test1', params: {}, id: 'request1' }), + transport.send({ jsonrpc: '2.0', method: 'test2', params: {}, id: 'request2' }) + ]); + + // Give time for SSE processing + await new Promise(resolve => setTimeout(resolve, 100)); + + // Both streams should have delivered their messages + expect(messageSpy).toHaveBeenCalledTimes(2); + + // Verify received messages without assuming specific order + expect( + messageSpy.mock.calls.some(call => { + const msg = call[0]; + return msg.id === 'request1' && msg.result?.id === 'request1'; + }) + ).toBe(true); + + expect( + messageSpy.mock.calls.some(call => { + const msg = call[0]; + return msg.id === 'request2' && msg.result?.id === 'request2'; + }) + ).toBe(true); + }); + + it('should support custom reconnection options', () => { + // Create a transport with custom reconnection options + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 500, + maxReconnectionDelay: 10_000, + reconnectionDelayGrowFactor: 2, + maxRetries: 5 + } + }); + + // Verify options were set correctly (checking implementation details) + // Access private properties for testing + const transportInstance = transport as unknown as { + _reconnectionOptions: StreamableHTTPReconnectionOptions; + }; + expect(transportInstance._reconnectionOptions.initialReconnectionDelay).toBe(500); + expect(transportInstance._reconnectionOptions.maxRetries).toBe(5); + }); + + it('should pass lastEventId when reconnecting', async () => { + // Create a fresh transport + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + // Mock fetch to verify headers sent + const fetchSpy = globalThis.fetch as Mock; + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + // Call the reconnect method directly with a lastEventId + await transport.start(); + // Type assertion to access private method + const transportWithPrivateMethods = transport as unknown as { + _startOrAuthSse: (options: { resumptionToken?: string }) => Promise; + }; + await transportWithPrivateMethods._startOrAuthSse({ resumptionToken: 'test-event-id' }); + + // Verify fetch was called with the lastEventId header + expect(fetchSpy).toHaveBeenCalled(); + const fetchCall = fetchSpy.mock.calls[0]!; + const headers = fetchCall[1].headers; + expect(headers.get('last-event-id')).toBe('test-event-id'); + }); + + it('should include requestInit options (credentials, mode, etc.) in GET SSE request', async () => { + // Regression test for #895: POST and DELETE requests spread _requestInit but the + // GET SSE request did not, so non-header options like credentials were dropped. + vi.clearAllMocks(); + + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { credentials: 'include', mode: 'cors' } + }); + + const fetchSpy = globalThis.fetch as Mock; + fetchSpy.mockReset(); + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + await transport.start(); + await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}); + + expect(fetchSpy).toHaveBeenCalled(); + const init = fetchSpy.mock.calls[0]![1]; + expect(init.method).toBe('GET'); + expect(init.credentials).toBe('include'); + expect(init.mode).toBe('cors'); + }); + + it('should throw error when invalid content-type is received', async () => { + // Clear any previous state from other tests + vi.clearAllMocks(); + + // Create a fresh transport instance + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('invalid text response')); + controller.close(); + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/plain' }), + body: stream + }); + + await transport.start(); + await expect(transport.send(message)).rejects.toThrow('Unexpected content type: text/plain'); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('uses custom fetch implementation if provided', async () => { + // Create custom fetch + const customFetch = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } })) + .mockResolvedValueOnce(new Response(null, { status: 202 })); + + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + fetch: customFetch + }); + + await transport.start(); + await (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {}, id: '1' } as JSONRPCMessage); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should always send specified custom headers', async () => { + const requestInit = { + headers: { + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'CustomValue' + } + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }); + + await transport.start(); + + await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); + + requestInit.headers['X-Custom-Header'] = 'SecondCustomValue'; + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('should always send specified custom headers (Headers class)', async () => { + const requestInit = { + headers: new Headers({ + Authorization: 'Bearer test-token', + 'X-Custom-Header': 'CustomValue' + }) + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: requestInit + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }); + + await transport.start(); + + await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); + + (requestInit.headers as Headers).set('X-Custom-Header', 'SecondCustomValue'); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('SecondCustomValue'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('should always send specified custom headers (array of tuples)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { + headers: [ + ['Authorization', 'Bearer test-token'], + ['X-Custom-Header', 'CustomValue'] + ] + } + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }); + + await transport.start(); + + await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('authorization')).toBe('Bearer test-token'); + expect((actualReqInit.headers as Headers).get('x-custom-header')).toBe('CustomValue'); + }); + + it('should append custom Accept header to required types on POST requests', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { + headers: { + Accept: 'application/vnd.example.v1+json' + } + } + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + }); + + await transport.start(); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get('accept')).toBe( + 'application/vnd.example.v1+json, application/json, text/event-stream' + ); + }); + + it('should append custom Accept header to required types on GET SSE requests', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { + headers: { + Accept: 'application/json' + } + } + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }); + + await transport.start(); + + await transport['_startOrAuthSse']({}); + expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream'); + }); + + it('should set default Accept header when none provided', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + }); + + await transport.start(); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream'); + }); + + it('should not duplicate Accept media types when user-provided value overlaps required types', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + requestInit: { + headers: { + Accept: 'application/json' + } + } + }); + + let actualReqInit: RequestInit = {}; + + (globalThis.fetch as Mock).mockImplementation(async (_url, reqInit) => { + actualReqInit = reqInit; + return new Response(JSON.stringify({ jsonrpc: '2.0', result: {} }), { + status: 200, + headers: { 'content-type': 'application/json' } + }); + }); + + await transport.start(); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect((actualReqInit.headers as Headers).get('accept')).toBe('application/json, text/event-stream'); + }); + + it('should have exponential backoff with configurable maxRetries', () => { + // This test verifies the maxRetries and backoff calculation directly + + // Create transport with specific options for testing + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3 + } + }); + + // Get access to the internal implementation + const getDelay = transport['_getNextReconnectionDelay'].bind(transport); + + // First retry - should use initial delay + expect(getDelay(0)).toBe(100); + + // Second retry - should double (2^1 * 100 = 200) + expect(getDelay(1)).toBe(200); + + // Third retry - should double again (2^2 * 100 = 400) + expect(getDelay(2)).toBe(400); + + // Fourth retry - should double again (2^3 * 100 = 800) + expect(getDelay(3)).toBe(800); + + // Tenth retry - should be capped at maxReconnectionDelay + expect(getDelay(10)).toBe(5000); + }); + + it('attempts auth flow on 401 during POST request', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }) + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => { + throw 'dont read my body'; + } + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); + }); + + it('attempts upscoping on 403 with WWW-Authenticate header', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const fetchMock = globalThis.fetch as Mock; + fetchMock + // First call: returns 403 with insufficient_scope + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': + 'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"' + }), + text: () => Promise.resolve('Insufficient scope') + }) + // Second call: successful after upscoping + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + // Verify fetch was called twice + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Verify auth was called with the new scope + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'new_scope', + resourceMetadataUrl: new URL('http://example.com/resource') + }) + ); + + authSpy.mockRestore(); + }); + + it('prevents infinite upscoping on repeated 403', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + // Mock fetch calls to always return 403 with insufficient_scope + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"' + }), + text: () => Promise.resolve('Insufficient scope') + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('../../src/client/auth.js'); + const authSpy = vi.spyOn(authModule as typeof import('../../src/client/auth.js'), 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + // First send: should trigger upscoping + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth + expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once + + // Second send: should fail immediately without re-calling auth + fetchMock.mockClear(); + authSpy.mockClear(); + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call + expect(authSpy).not.toHaveBeenCalled(); // Auth not called again + + authSpy.mockRestore(); + }); + + describe('Reconnection Logic', () => { + let transport: StreamableHTTPClientTransport; + + // Use fake timers to control setTimeout and make the test instant. + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('should reconnect a GET-initiated notification stream that fails', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + const failingStream = new ReadableStream({ + start(controller) { + controller.error(new Error('Network failure')); + } + }); + + const fetchMock = globalThis.fetch as Mock; + // Mock the initial GET request, which will fail. + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: failingStream + }); + // Mock the reconnection GET request, which will succeed. + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + // ACT + await transport.start(); + // Trigger the GET stream directly using the internal method for a clean test. + await transport['_startOrAuthSse']({}); + await vi.advanceTimersByTimeAsync(20); // Trigger reconnection timeout + + // ASSERT + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('SSE stream disconnected: Error: Network failure') + }) + ); + // THE KEY ASSERTION: A second fetch call proves reconnection was attempted. + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET'); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + }); + + it('should NOT reconnect a POST-initiated stream that fails', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, // Ensure it doesn't retry indefinitely + reconnectionDelayGrowFactor: 1 // No exponential backoff for simplicity + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + const failingStream = new ReadableStream({ + start(controller) { + controller.error(new Error('Network failure')); + } + }); + + const fetchMock = globalThis.fetch as Mock; + // Mock the POST request. It returns a streaming content-type but a failing body. + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: failingStream + }); + + // A dummy request message to trigger the `send` logic. + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'long_running_tool', + id: 'request-1', + params: {} + }; + + // ACT + await transport.start(); + // Use the public `send` method to initiate a POST that gets a stream response. + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(20); // Advance time to check for reconnections + + // ASSERT + // THE KEY ASSERTION: Fetch was only called ONCE. No reconnection was attempted. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + }); + + it('should reconnect a POST-initiated stream after receiving a priming event', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + // Create a stream that sends a priming event (with ID) then closes + const streamWithPrimingEvent = new ReadableStream({ + start(controller) { + // Send a priming event with an ID - this enables reconnection + controller.enqueue( + new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n\n') + ); + // Then close the stream (simulating server disconnect) + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + // First call: POST returns streaming response with priming event + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithPrimingEvent + }); + // Second call: GET reconnection - return 405 to stop further reconnection + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 405, + headers: new Headers() + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'long_running_tool', + id: 'request-1', + params: {} + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + // Wait for stream to process and reconnection to be scheduled + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // Verify we performed at least one POST for the initial stream. + expect(fetchMock).toHaveBeenCalled(); + const postCall = fetchMock.mock.calls.find(call => call[1]?.method === 'POST'); + expect(postCall).toBeDefined(); + }); + + it('should NOT reconnect a POST stream when response was received', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + // Create a stream that sends: + // 1. Priming event with ID (enables potential reconnection) + // 2. The actual response (should prevent reconnection) + // 3. Then closes + const streamWithResponse = new ReadableStream({ + start(controller) { + // Priming event with ID + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // The actual response to the request + controller.enqueue( + new TextEncoder().encode('id: response-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"request-1"}\n\n') + ); + // Stream closes normally + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithResponse + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'tools/list', + id: 'request-1', + params: {} + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // THE KEY ASSERTION: Fetch was called ONCE only - no reconnection! + // The response was received, so no need to reconnect. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + }); + + it('should NOT reconnect a POST stream when error response was received', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + // Create a stream that sends: + // 1. Priming event with ID (enables potential reconnection) + // 2. An error response (should also prevent reconnection, just like success) + // 3. Then closes + const streamWithErrorResponse = new ReadableStream({ + start(controller) { + // Priming event with ID + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // An error response to the request (tool not found, for example) + controller.enqueue( + new TextEncoder().encode( + 'id: error-456\ndata: {"jsonrpc":"2.0","error":{"code":-32602,"message":"Tool not found"},"id":"request-1"}\n\n' + ) + ); + // Stream closes normally + controller.close(); + } + }); + + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithErrorResponse + }); + + const requestMessage: JSONRPCRequest = { + jsonrpc: '2.0', + method: 'tools/call', + id: 'request-1', + params: { name: 'nonexistent-tool' } + }; + + // ACT + await transport.start(); + await transport.send(requestMessage); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT + // THE KEY ASSERTION: Fetch was called ONCE only - no reconnection! + // The error response was received, so no need to reconnect. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + + // Verify the error response was delivered to the message handler + expect(messageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32602, + message: 'Tool not found' + }), + id: 'request-1' + }) + ); + }); + + it('should not attempt reconnection after close() is called', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxRetries: 3, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + // Stream with priming event + notification (no response) that closes + // This triggers reconnection scheduling + const streamWithPriming = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode('id: event-123\ndata: {"jsonrpc":"2.0","method":"notifications/test","params":{}}\n\n') + ); + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + + // POST request returns streaming response + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithPriming + }); + + // ACT + await transport.start(); + await transport.send({ jsonrpc: '2.0', method: 'test', id: '1', params: {} }); + + // Wait a tick to let stream processing complete and schedule reconnection + await vi.advanceTimersByTimeAsync(10); + + // Now close() - reconnection timeout is pending (scheduled for 100ms) + await transport.close(); + + // Advance past reconnection delay + await vi.advanceTimersByTimeAsync(200); + + // ASSERT + // Only 1 call: the initial POST. No reconnection attempts after close(). + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); + }); + + it('should not throw JSON parse error on priming events with empty data', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + const resumptionTokenSpy = vi.fn(); + + // Create a stream that sends a priming event (ID only, empty data) then a real message + const streamWithPrimingEvent = new ReadableStream({ + start(controller) { + // Send a priming event with ID but empty data - this should NOT cause a JSON parse error + controller.enqueue(new TextEncoder().encode('id: priming-123\ndata: \n\n')); + // Send a real message + controller.enqueue( + new TextEncoder().encode('id: msg-456\ndata: {"jsonrpc":"2.0","result":{"tools":[]},"id":"req-1"}\n\n') + ); + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: streamWithPrimingEvent + }); + + await transport.start(); + transport.send( + { + jsonrpc: '2.0', + method: 'tools/list', + id: 'req-1', + params: {} + }, + { resumptionToken: undefined, onresumptiontoken: resumptionTokenSpy } + ); + + await vi.advanceTimersByTimeAsync(50); + + // No JSON parse errors should have occurred + expect(errorSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Unexpected end of JSON') }) + ); + // Resumption token callback may be invoked, but the primary assertion + // here is that no JSON parse errors occurred for the priming event. + }); + }); + + it('invalidates all credentials on OAuthErrorCode.InvalidClient during auth', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + refresh_token: 'test-refresh' + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }; + (globalThis.fetch as Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Token refresh fails with OAuthErrorCode.InvalidClient + .mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed').toResponseObject(), { + status: 400 + }) + ) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + // Ensure the auth flow completes without unhandled rejections for this + // error type; token invalidation behavior is covered in dedicated tests. + await transport.send(message).catch(() => {}); + }); + + it('invalidates all credentials on OAuthErrorCode.UnauthorizedClient during auth', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + refresh_token: 'test-refresh' + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }; + (globalThis.fetch as Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Token refresh fails with OAuthErrorCode.UnauthorizedClient + .mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.UnauthorizedClient, 'Client not authorized').toResponseObject(), { + status: 400 + }) + ) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => { + throw 'dont read my body'; + } + }); + + // As above, just ensure the auth flow completes without unhandled + // rejections in this scenario. + await transport.send(message).catch(() => {}); + }); + + it('invalidates tokens on OAuthErrorCode.InvalidGrant during auth', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + refresh_token: 'test-refresh' + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }; + (globalThis.fetch as Mock) + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Token refresh fails with OAuthErrorCode.InvalidGrant + .mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.InvalidGrant, 'Invalid refresh token').toResponseObject(), { status: 400 }) + ) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => { + throw 'dont read my body'; + } + }); + + // Behavior for OAuthErrorCode.InvalidGrant during auth is covered in dedicated OAuth + // unit tests and SSE transport tests. Here we just assert that the call + // path completes without unhandled rejections. + await transport.send(message).catch(() => {}); + }); + + describe('custom fetch in auth code paths', () => { + it('uses custom fetch during auth flow on 401 - no global fetch fallback', async () => { + const unauthedResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }; + + // Create custom fetch + const customFetch = vi + .fn() + // Initial connection + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Token refresh fails with OAuthErrorCode.InvalidClient + .mockResolvedValueOnce( + Response.json(new OAuthError(OAuthErrorCode.InvalidClient, 'Client authentication failed').toResponseObject(), { + status: 400 + }) + ) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404 + }); + + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError + await transport.start(); + await expect( + (transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise })._startOrAuthSse({}) + ).rejects.toThrow(UnauthorizedError); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Verify auth provider was called to redirect to authorization + expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); + + // Global fetch should never have been called + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('uses custom fetch in finishAuth method - no global fetch fallback', async () => { + // Create custom fetch + const customFetch = vi + .fn() + // Protected resource metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + authorization_servers: ['http://localhost:1234'], + resource: 'http://localhost:1234/mcp' + }) + }) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Code exchange + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + + // Create transport instance + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + // Call finishAuth with authorization code + await transport.finishAuth('test-auth-code'); + + // Verify custom fetch was used + expect(customFetch).toHaveBeenCalled(); + + // Verify specific OAuth endpoints were called with custom fetch + const customFetchCalls = customFetch.mock.calls; + const callUrls = customFetchCalls.map(([url]) => url.toString()); + + // Should have called resource metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); + + // Should have called OAuth authorization server metadata discovery + expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); + + // Should have called token endpoint for authorization code exchange + const tokenCalls = customFetchCalls.filter(([url, options]) => url.toString().includes('/token') && options?.method === 'POST'); + expect(tokenCalls.length).toBeGreaterThan(0); + + // Verify tokens were saved + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }); + + // Global fetch should never have been called + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('SSE retry field handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + (globalThis.fetch as Mock).mockReset(); + }); + afterEach(() => vi.useRealTimers()); + + it('should use server-provided retry value for reconnection delay', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3 + } + }); + + // Create a stream that sends a retry field + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send SSE event with retry field + const event = + 'retry: 3000\nevent: message\nid: evt-1\ndata: {"jsonrpc": "2.0", "method": "notification", "params": {}}\n\n'; + controller.enqueue(encoder.encode(event)); + // Close stream to trigger reconnection + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + + // Second request for reconnection + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + await transport.start(); + await transport['_startOrAuthSse']({}); + + // Wait for stream to close and reconnection to be scheduled + await vi.advanceTimersByTimeAsync(100); + + // Verify the server retry value was captured + const transportInternal = transport as unknown as { _serverRetryMs?: number }; + expect(transportInternal._serverRetryMs).toBe(3000); + + // Verify the delay calculation uses server retry value + const getDelay = transport['_getNextReconnectionDelay'].bind(transport); + expect(getDelay(0)).toBe(3000); // Should use server value, not 100ms initial + expect(getDelay(5)).toBe(3000); // Should still use server value for any attempt + }); + + it('should fall back to exponential backoff when no server retry value', () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 100, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3 + } + }); + + // Without any SSE stream, _serverRetryMs should be undefined + const transportInternal = transport as unknown as { _serverRetryMs?: number }; + expect(transportInternal._serverRetryMs).toBeUndefined(); + + // Should use exponential backoff + const getDelay = transport['_getNextReconnectionDelay'].bind(transport); + expect(getDelay(0)).toBe(100); // 100 * 2^0 + expect(getDelay(1)).toBe(200); // 100 * 2^1 + expect(getDelay(2)).toBe(400); // 100 * 2^2 + expect(getDelay(10)).toBe(5000); // capped at max + }); + + it('should reconnect on graceful stream close', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1, + maxRetries: 1 + } + }); + + // Create a stream that closes gracefully after sending an event with ID + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send priming event with ID and retry field + const event = 'id: evt-1\nretry: 100\ndata: \n\n'; + controller.enqueue(encoder.encode(event)); + // Graceful close + controller.close(); + } + }); + + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + + // Second request for reconnection + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: new ReadableStream() + }); + + await transport.start(); + await transport['_startOrAuthSse']({}); + + // Wait for stream to process and close + await vi.advanceTimersByTimeAsync(50); + + // Wait for reconnection delay (100ms from retry field) + await vi.advanceTimersByTimeAsync(150); + + // Should have attempted reconnection + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('GET'); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + + // Second call should include Last-Event-ID + const secondCallHeaders = fetchMock.mock.calls[1]![1]?.headers; + expect(secondCallHeaders?.get('last-event-id')).toBe('evt-1'); + }); + }); + + describe('Reconnection Logic with maxRetries 0', () => { + let transport: StreamableHTTPClientTransport; + + // Use fake timers to control setTimeout and make the test instant. + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('should not schedule any reconnection attempts when maxRetries is 0', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 0, // This should disable retries completely + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + // ACT - directly call _scheduleReconnection which is the code path the fix affects + transport['_scheduleReconnection']({}); + + // ASSERT - should immediately report max retries exceeded, not schedule a retry + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Maximum reconnection attempts (0) exceeded.' + }) + ); + + // Verify no reconnection was scheduled + expect(transport['_cancelReconnection']).toBeUndefined(); + }); + + it('should schedule reconnection when maxRetries is greater than 0', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, // Allow 1 retry + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + // ACT - call _scheduleReconnection with attemptCount 0 + transport['_scheduleReconnection']({}); + + // ASSERT - should schedule a reconnection, not report error yet + expect(errorSpy).not.toHaveBeenCalled(); + expect(transport['_cancelReconnection']).toBeDefined(); + + // Clean up the pending reconnection to avoid test pollution + transport['_cancelReconnection']?.(); + }); + }); + + describe('prevent infinite recursion when server returns 401 after successful auth', () => { + it('should throw error when server returns 401 after successful auth', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + // Mock provider with refresh token to enable token refresh flow + mockAuthProvider.tokens.mockResolvedValue({ + access_token: 'test-token', + token_type: 'Bearer', + refresh_token: 'refresh-token' + }); + + const unauthedResponse = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => { + throw 'dont read my body'; + } + }; + + (globalThis.fetch as Mock) + // First request - 401, triggers auth flow + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, path aware + .mockResolvedValueOnce(unauthedResponse) + // Resource discovery, root + .mockResolvedValueOnce(unauthedResponse) + // OAuth metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'http://localhost:1234', + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }) + // Token refresh succeeds + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600 + }) + }) + // Retry the original request - still 401 (broken server) + .mockResolvedValueOnce(unauthedResponse); + + const error = await transport.send(message).catch(e => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token' // Refresh token is preserved + }); + }); + }); + + describe('reconnectionScheduler', () => { + const reconnectionOptions: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 5000, + reconnectionDelayGrowFactor: 2, + maxRetries: 3 + }; + + function triggerReconnection(t: StreamableHTTPClientTransport): void { + (t as unknown as { _scheduleReconnection(opts: StartSSEOptions, attempt?: number): void })._scheduleReconnection({}, 0); + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('invokes the custom scheduler with reconnect, delay, and attemptCount', () => { + const scheduler = vi.fn(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: scheduler + }); + + triggerReconnection(transport); + + expect(scheduler).toHaveBeenCalledTimes(1); + expect(scheduler).toHaveBeenCalledWith(expect.any(Function), 1000, 0); + }); + + it('falls back to setTimeout when no scheduler is provided', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions + }); + + triggerReconnection(transport); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it('does not use setTimeout when a custom scheduler is provided', () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: vi.fn() + }); + + triggerReconnection(transport); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('calls the returned cancel function on close()', async () => { + const cancel = vi.fn(); + const scheduler: ReconnectionScheduler = vi.fn(() => cancel); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: scheduler + }); + + triggerReconnection(transport); + expect(cancel).not.toHaveBeenCalled(); + + await transport.close(); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it('tolerates schedulers that return void (no cancel function)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: () => { + /* no return */ + } + }); + + triggerReconnection(transport); + await expect(transport.close()).resolves.toBeUndefined(); + }); + + it('clears the default setTimeout on close() when no scheduler is provided', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions + }); + + triggerReconnection(transport); + await transport.close(); + + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + }); + + it('ignores a late-firing reconnect after close()', async () => { + let capturedReconnect: (() => void) | undefined; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: reconnect => { + capturedReconnect = reconnect; + } + }); + const onerror = vi.fn(); + transport.onerror = onerror; + + await transport.start(); + triggerReconnection(transport); + await transport.close(); + + capturedReconnect?.(); + await vi.runAllTimersAsync(); + + expect(onerror).not.toHaveBeenCalled(); + }); + + it('still aborts and fires onclose if the cancel function throws', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions, + reconnectionScheduler: () => () => { + throw new Error('cancel failed'); + } + }); + const onclose = vi.fn(); + transport.onclose = onclose; + + await transport.start(); + triggerReconnection(transport); + const abortController = transport['_abortController']; + + await expect(transport.close()).rejects.toThrow('cancel failed'); + expect(abortController?.signal.aborted).toBe(true); + expect(onclose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/client/test/client/tokenProvider.test.ts b/packages/client/test/client/tokenProvider.test.ts new file mode 100644 index 0000000..e110826 --- /dev/null +++ b/packages/client/test/client/tokenProvider.test.ts @@ -0,0 +1,316 @@ +import type { IncomingMessage, Server } from 'node:http'; +import { createServer } from 'node:http'; + +import type { JSONRPCMessage, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core'; +import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import type { Mock } from 'vitest'; + +import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth.js'; +import { UnauthorizedError } from '../../src/client/auth.js'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; + +describe('StreamableHTTPClientTransport with AuthProvider', () => { + let transport: StreamableHTTPClientTransport; + + afterEach(async () => { + await transport?.close().catch(() => {}); + vi.clearAllMocks(); + }); + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: {}, id: 'test-id' }; + + it('should set Authorization header from AuthProvider.token()', async () => { + const authProvider: AuthProvider = { token: vi.fn(async () => 'my-bearer-token') }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + await transport.send(message); + + expect(authProvider.token).toHaveBeenCalled(); + const [, init] = (globalThis.fetch as Mock).mock.calls[0]!; + expect(init.headers.get('Authorization')).toBe('Bearer my-bearer-token'); + }); + + it('should not set Authorization header when token() returns undefined', async () => { + const authProvider: AuthProvider = { token: vi.fn(async () => undefined) }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + await transport.send(message); + + const [, init] = (globalThis.fetch as Mock).mock.calls[0]!; + expect(init.headers.has('Authorization')).toBe(false); + }); + + it('should throw UnauthorizedError on 401 when onUnauthorized is not provided', async () => { + const authProvider: AuthProvider = { token: vi.fn(async () => 'rejected-token') }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + headers: new Headers(), + text: async () => 'unauthorized' + }); + + await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); + expect(authProvider.token).toHaveBeenCalledTimes(1); + }); + + it('should call onUnauthorized and retry once on 401', async () => { + let currentToken = 'old-token'; + const authProvider: AuthProvider = { + token: vi.fn(async () => currentToken), + onUnauthorized: vi.fn(async () => { + currentToken = 'new-token'; + }) + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }) + .mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + await transport.send(message); + + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(authProvider.token).toHaveBeenCalledTimes(2); + const [, retryInit] = (globalThis.fetch as Mock).mock.calls[1]!; + expect(retryInit.headers.get('Authorization')).toBe('Bearer new-token'); + }); + + it('should throw SdkHttpError(ClientHttpAuthentication) if retry after onUnauthorized also gets 401', async () => { + const authProvider: AuthProvider = { + token: vi.fn(async () => 'still-bad'), + onUnauthorized: vi.fn(async () => {}) + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }); + + const error = await transport.send(message).catch(e => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + }); + + it('should reset retry guard when onUnauthorized throws, allowing retry on next send', async () => { + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn().mockRejectedValueOnce(new Error('transient network error')).mockResolvedValueOnce(undefined) + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }) + .mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + // First send: onUnauthorized throws transient error + await expect(transport.send(message)).rejects.toThrow('transient network error'); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + + // Second send: flag should be reset, so onUnauthorized gets a second chance + await transport.send(message); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + }); + + it('should work with no authProvider at all', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + + await transport.send(message); + + const [, init] = (globalThis.fetch as Mock).mock.calls[0]!; + expect(init.headers.has('Authorization')).toBe(false); + }); + + it('should throw when finishAuth is called with a non-OAuth AuthProvider', async () => { + const authProvider: AuthProvider = { token: async () => 'api-key' }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + + await expect(transport.finishAuth('auth-code')).rejects.toThrow('finishAuth requires an OAuthClientProvider'); + }); + + it('should throw UnauthorizedError on GET-SSE 401 with no onUnauthorized (via resumeStream)', async () => { + const authProvider: AuthProvider = { token: async () => 'api-key' }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + headers: new Headers(), + text: async () => 'unauthorized' + }); + + await expect(transport.resumeStream('last-event-id')).rejects.toThrow(UnauthorizedError); + }); + + it('should call onUnauthorized and retry on GET-SSE 401 (via resumeStream)', async () => { + let currentToken = 'old-token'; + const authProvider: AuthProvider = { + token: vi.fn(async () => currentToken), + onUnauthorized: vi.fn(async () => { + currentToken = 'new-token'; + }) + }; + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { authProvider }); + vi.spyOn(globalThis, 'fetch'); + + // First GET: 401. Second GET (retry): 405 (server doesn't offer SSE — clean exit) + (globalThis.fetch as Mock) + .mockResolvedValueOnce({ ok: false, status: 401, headers: new Headers(), text: async () => 'unauthorized' }) + .mockResolvedValueOnce({ ok: false, status: 405, headers: new Headers(), text: async () => '' }); + + await transport.resumeStream('last-event-id'); + + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(authProvider.token).toHaveBeenCalledTimes(2); + const [, retryInit] = (globalThis.fetch as Mock).mock.calls[1]!; + expect(retryInit.headers.get('Authorization')).toBe('Bearer new-token'); + }); +}); + +describe('AuthProvider integration — both modes against a real server', () => { + let server: Server; + let serverUrl: URL; + let capturedRequests: IncomingMessage[]; + let transport: StreamableHTTPClientTransport; + + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'ping', params: {}, id: '1' }; + + beforeEach(async () => { + capturedRequests = []; + server = createServer((req, res) => { + capturedRequests.push(req); + if (req.method === 'POST') { + // Consume body then respond 202 Accepted + req.on('data', () => {}); + req.on('end', () => res.writeHead(202).end()); + } else { + // GET SSE — reject so the transport skips it + res.writeHead(405).end(); + } + }); + serverUrl = await listenOnRandomPort(server); + }); + + afterEach(async () => { + await transport?.close().catch(() => {}); + await new Promise(resolve => server.close(() => resolve())); + }); + + it('MODE A: minimal AuthProvider { token } sends Authorization header', async () => { + const authProvider: AuthProvider = { token: async () => 'mode-a-token' }; + transport = new StreamableHTTPClientTransport(serverUrl, { authProvider }); + + await transport.send(message); + + expect(capturedRequests).toHaveLength(1); + expect(capturedRequests[0]!.headers.authorization).toBe('Bearer mode-a-token'); + }); + + it('MODE A: onUnauthorized signals and throws — caller sees the error', async () => { + const uiSignal = vi.fn(); + const authProvider: AuthProvider = { + token: async () => 'rejected-token', + onUnauthorized: async () => { + uiSignal('show-reauth-prompt'); + throw new UnauthorizedError('user action required'); + } + }; + + // Server that rejects with 401 + await new Promise(resolve => server.close(() => resolve())); + server = createServer((req, res) => { + capturedRequests.push(req); + req.on('data', () => {}); + req.on('end', () => res.writeHead(401).end()); + }); + serverUrl = await listenOnRandomPort(server); + + transport = new StreamableHTTPClientTransport(serverUrl, { authProvider }); + + await expect(transport.send(message)).rejects.toThrow('user action required'); + expect(uiSignal).toHaveBeenCalledWith('show-reauth-prompt'); + }); + + it('MODE B: OAuthClientProvider is adapted — tokens() becomes token() on the wire', async () => { + // Minimal OAuthClientProvider — the transport should adapt it via adaptOAuthProvider + const oauthProvider: OAuthClientProvider = { + get redirectUrl() { + return undefined; + }, + get clientMetadata(): OAuthClientMetadata { + return { redirect_uris: [], grant_types: ['client_credentials'] }; + }, + clientInformation(): OAuthClientInformation { + return { client_id: 'test-client' }; + }, + tokens(): OAuthTokens { + return { access_token: 'mode-b-oauth-token', token_type: 'bearer' }; + }, + saveTokens() {}, + redirectToAuthorization() { + throw new Error('not used'); + }, + saveCodeVerifier() {}, + codeVerifier() { + throw new Error('not used'); + } + }; + + transport = new StreamableHTTPClientTransport(serverUrl, { authProvider: oauthProvider }); + + await transport.send(message); + + expect(capturedRequests).toHaveLength(1); + expect(capturedRequests[0]!.headers.authorization).toBe('Bearer mode-b-oauth-token'); + }); + + it('both modes use the same option slot and same send() call', async () => { + // Mode A + const transportA = new StreamableHTTPClientTransport(serverUrl, { + authProvider: { token: async () => 'a-token' } + }); + await transportA.send(message); + await transportA.close(); + + // Mode B — same constructor, same option name, different shape + const transportB = new StreamableHTTPClientTransport(serverUrl, { + authProvider: { + get redirectUrl() { + return undefined; + }, + get clientMetadata(): OAuthClientMetadata { + return { redirect_uris: [] }; + }, + clientInformation: () => ({ client_id: 'x' }), + tokens: () => ({ access_token: 'b-token', token_type: 'bearer' }), + saveTokens() {}, + redirectToAuthorization() {}, + saveCodeVerifier() {}, + codeVerifier: () => '' + } satisfies OAuthClientProvider + }); + await transportB.send(message); + await transportB.close(); + + expect(capturedRequests.map(r => r.headers.authorization)).toEqual(['Bearer a-token', 'Bearer b-token']); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..a40ee9f --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/core/validators/cfWorker": [ + "./node_modules/@modelcontextprotocol/core/src/validators/cfWorkerProvider.ts" + ], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./src/shimsNode.ts"] + } + } +} diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts new file mode 100644 index 0000000..c547e6e --- /dev/null +++ b/packages/client/tsdown.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + // 1. Entry Points + // Directly matches package.json include/exclude globs + entry: ['src/index.ts', 'src/stdio.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/shimsBrowser.ts', 'src/validators/cfWorker.ts'], + + // 2. Output Configuration + format: ['esm'], + outDir: 'dist', + clean: true, // Recommended: Cleans 'dist' before building + sourcemap: true, + + // 3. Platform & Target + target: 'esnext', + platform: 'node', + shims: true, // Polyfills common Node.js shims (__dirname, etc.) + + // 4. Type Definitions + // Bundles d.ts files into a single output + dts: { + resolver: 'tsc', + // override just for DTS generation: + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/core': ['../core/src/index.ts'], + '@modelcontextprotocol/core/public': ['../core/src/exports/public/index.ts'], + '@modelcontextprotocol/core/validators/cfWorker': ['../core/src/validators/cfWorkerProvider.ts'] + } + } + }, + // 5. Vendoring Strategy - Bundle the code for this specific package into the output, + // but treat all other dependencies as external (require/import). + noExternal: ['@modelcontextprotocol/core'], + + // 6. External packages - keep self-reference imports external for runtime resolution + external: ['@modelcontextprotocol/client/_shims'] +}); diff --git a/packages/client/typedoc.json b/packages/client/typedoc.json new file mode 100644 index 0000000..dd70079 --- /dev/null +++ b/packages/client/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/client/vitest.config.js b/packages/client/vitest.config.js new file mode 100644 index 0000000..2012fa5 --- /dev/null +++ b/packages/client/vitest.config.js @@ -0,0 +1,8 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; +import { mergeConfig } from 'vitest/config'; + +export default mergeConfig(baseConfig, { + test: { + setupFiles: ['./vitest.setup.js'] + } +}); diff --git a/packages/client/vitest.setup.js b/packages/client/vitest.setup.js new file mode 100644 index 0000000..d6e9c66 --- /dev/null +++ b/packages/client/vitest.setup.js @@ -0,0 +1,7 @@ +import { webcrypto } from 'node:crypto'; + +// Polyfill globalThis.crypto for environments (e.g. Node 18) where it is not defined. +// This is necessary for the tests to run in Node 18, specifically for the jose library, which relies on the globalThis.crypto object. +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = webcrypto; +} diff --git a/packages/codemod/batch-test/.gitignore b/packages/codemod/batch-test/.gitignore new file mode 100644 index 0000000..9be0a0f --- /dev/null +++ b/packages/codemod/batch-test/.gitignore @@ -0,0 +1,3 @@ +repos/ +results/ +tarballs/ diff --git a/packages/codemod/batch-test/README.md b/packages/codemod/batch-test/README.md new file mode 100644 index 0000000..190d36d --- /dev/null +++ b/packages/codemod/batch-test/README.md @@ -0,0 +1,102 @@ +# Codemod Batch Test + +Tests the v1-to-v2 codemod against real-world repos to find bugs, missing transforms, and gaps. + +## How it works + +For each repo in `repos.json`, the batch test: + +1. Clones the repo (or resets an existing clone) +2. Installs dependencies +3. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy +4. Runs the codemod using the programmatic API +5. Packs local SDK packages as tarballs and rewrites `package.json` deps to use them (so the test runs against the current SDK branch, not published npm versions) +6. Re-installs dependencies +7. Re-runs the same checks +8. Writes structured JSON reports + +Errors that appear in step 7 but not step 3 are codemod-introduced regressions. + +## Usage + +```bash +# Build all SDK packages first (tarballs need built dist/) +pnpm build:all + +# Run the batch test +pnpm --filter @modelcontextprotocol/codemod batch-test + +# Clean cloned repos, results, and tarballs +pnpm --filter @modelcontextprotocol/codemod batch-test:clean +``` + +## Output + +Results are written to `batch-test/results/`: + +- `summary.json` — overview across all repos: which passed, which failed, error counts +- `/report.json` — per-repo detail: baseline vs post-codemod check results, codemod diagnostics, change counts + +## Repo manifest (`repos.json`) + +An array of repo entries. Each entry specifies a GitHub repo and one or more packages within it. + +```json +{ + "repo": "owner/repo-name", + "ref": "main", + "packages": [ + { + "dir": "packages/mcp-server", + "sourceDir": "src", + "checks": { + "typecheck": "npx tsc --noEmit", + "build": "npm run build", + "test": "npm run test", + "lint": null + } + } + ] +} +``` + +| Field | Required | Default | Description | +| ---------------------- | -------- | -------------------------------------- | ----------------------------------------------------------------- | +| `repo` | yes | — | GitHub `owner/name` | +| `ref` | no | `main` | Branch or tag to clone | +| `packages` | no | `[{ "dir": ".", "sourceDir": "src" }]` | Package targets within the repo | +| `packages[].dir` | yes | — | Path to package root (where `package.json` lives) | +| `packages[].sourceDir` | no | `src` | Source directory relative to `dir` (passed to codemod) | +| `packages[].checks` | no | auto-detect | Override check commands; set a value to `null` to skip that check | + +When `checks` is omitted, the runner auto-detects commands from the package's `package.json` scripts (probing names like `typecheck`, `build`, `test`, `lint`). The package manager is auto-detected from the lockfile at the repo root. + +## Analyzing results + +`analyze-prompt.md` contains instructions for Claude Code to run the batch test and produce a categorized analysis. Each error is classified as: + +| Category | Meaning | +| ------------------- | -------------------------------------------------- | +| `codemod-bug` | A transform produced incorrect output | +| `missing-transform` | The codemod should handle this pattern but doesn't | +| `manual-migration` | Expected — requires human judgment | +| `repo-specific` | Unusual pattern not worth handling in the codemod | + +## Adding a repo + +1. Edit `repos.json` and add an entry +2. Run `pnpm --filter @modelcontextprotocol/codemod batch-test` +3. Check `results//report.json` for new findings + +For monorepos, list each package that uses `@modelcontextprotocol/sdk` as a separate entry in `packages`. + +## Iteration workflow + +``` +1. Run the batch test +2. Review results — identify codemod bugs / missing transforms +3. Fix the codemod transforms +4. Run batch-test:clean, then re-run the batch test +5. Confirm the fixes resolved the issues +6. Repeat +``` diff --git a/packages/codemod/batch-test/analyze-prompt.md b/packages/codemod/batch-test/analyze-prompt.md new file mode 100644 index 0000000..81f206b --- /dev/null +++ b/packages/codemod/batch-test/analyze-prompt.md @@ -0,0 +1,87 @@ +# Codemod Batch Test: Analysis + +## Context + +The MCP TypeScript SDK is migrating from a single v1 package (`@modelcontextprotocol/sdk`) to a multi-package v2 architecture (`@modelcontextprotocol/client`, `/server`, `/core`, `/node`, `/express`). This involves renamed APIs, restructured context objects, removed modules, and +new import paths. + +The `@modelcontextprotocol/codemod` package automates the mechanical parts of this migration. It runs 9 ordered AST transforms via ts-morph: import path rewrites, symbol renames, McpServer API restructuring, handler registration changes, context property remapping, and more. It +also updates `package.json` to swap v1 deps for v2. + +The **batch test** runs this codemod against a curated list of real-world repos that use the v1 SDK. For each repo it: + +1. Clones and installs +2. Runs baseline checks (typecheck, build, test, lint) to confirm the repo is healthy before migration +3. Runs the codemod +4. Re-installs (package.json was updated with v2 deps) +5. Re-runs the same checks + +The goal is to find issues in the codemod itself — incorrect transforms, missing transforms, or gaps — so we can fix them. + +## Instructions + +1. Build the codemod: + + ``` + pnpm --filter @modelcontextprotocol/codemod build + ``` + +2. Run the batch test: + + ``` + pnpm --filter @modelcontextprotocol/codemod batch-test + ``` + +3. Read `packages/codemod/batch-test/results/summary.json` for the overview. Note which repos have `postCodemodClean: false` and which check types have new errors. + +4. For each repo with new errors, read its `packages/codemod/batch-test/results//report.json`. Compare `baseline` vs `postCodemod` for each check — only errors that appear in `postCodemod` but not in `baseline` are codemod-introduced. + +5. Also review the `codemod.diagnostics` array in each report — these are warnings the codemod itself emitted about patterns it couldn't fully handle. + +6. For each codemod-introduced error, look at the actual source file in the cloned repo (`packages/codemod/batch-test/repos//...`) to understand what the codemod produced and what it should have produced. + +7. Categorize each finding using the categories below, then produce the output described in the Output Format section. + +## Error Categories + +| Category | Meaning | What to do | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `codemod-bug` | A transform produced incorrect output — the code it generated is wrong | Identify which transform is responsible and what the correct output should be. This is a bug to fix in the codemod. | +| `missing-transform` | The codemod left v1 code untouched that it should have migrated | Identify the v1 pattern and which existing transform should handle it, or whether a new transform is needed. | +| `manual-migration` | The error is expected — the migration guide documents this as requiring human judgment (e.g., removed APIs, architectural changes) | Verify the codemod emitted a diagnostic for it. If not, add one. | +| `repo-specific` | An unusual pattern unique to this repo that isn't worth handling in the codemod | Note it briefly but don't suggest codemod changes. | + +## Output Format + +### Summary + +- Repos tested: X +- Repos clean after codemod: Y +- Repos with new errors: Z +- Total codemod-introduced errors: N + +### Findings by Category + +#### Codemod Bugs + +| Repo | File:Line | Error | Transform | Root Cause | Correct Output | +| ---- | --------- | ----- | --------- | ---------- | -------------- | + +#### Missing Transforms + +| Repo | File:Line | Error | v1 Pattern | Suggested Fix | +| ---- | --------- | ----- | ---------- | ------------- | + +#### Manual Migration (expected) + +| Repo | File:Line | Error | Has Diagnostic? | Migration Guide Reference | +| ---- | --------- | ----- | --------------- | ------------------------- | + +#### Repo-Specific + +| Repo | File:Line | Error | Why Not Worth Handling | +| ---- | --------- | ----- | ---------------------- | + +### Priority Fixes + +List the top 3-5 codemod improvements that would fix the most repos, ordered by impact (number of repos affected). For each, state: what to change, in which transform, and how many repos it would fix. diff --git a/packages/codemod/batch-test/repos.json b/packages/codemod/batch-test/repos.json new file mode 100644 index 0000000..082acab --- /dev/null +++ b/packages/codemod/batch-test/repos.json @@ -0,0 +1,44 @@ +[ + { + "repo": "KKonstantinov/mcp-servers-fork", + "ref": "feature/upgrade-zod-v4", + "packages": [ + { + "dir": "src/everything", + "sourceDir": ".", + "checks": { + "typecheck": "npx tsc --noEmit", + "build": "npm run build", + "test": "npm run test", + "lint": "npm run prettier:check" + } + } + ] + }, + { + "repo": "modelcontextprotocol/inspector", + "ref": "main", + "packages": [ + { + "dir": "client", + "sourceDir": "src", + "checks": { + "typecheck": "npx tsc --noEmit", + "build": "npm run build", + "test": "npm run test", + "lint": "npm run lint" + } + }, + { + "dir": "server", + "sourceDir": "src", + "checks": { + "typecheck": "npx tsc --noEmit", + "build": "npm run build", + "test": null, + "lint": null + } + } + ] + } +] diff --git a/packages/codemod/eslint.config.mjs b/packages/codemod/eslint.config.mjs new file mode 100644 index 0000000..c1267b7 --- /dev/null +++ b/packages/codemod/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [...baseConfig]; diff --git a/packages/codemod/package.json b/packages/codemod/package.json new file mode 100644 index 0000000..264f973 --- /dev/null +++ b/packages/codemod/package.json @@ -0,0 +1,71 @@ +{ + "name": "@modelcontextprotocol/codemod", + "version": "2.0.0-alpha.0", + "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "codemod", + "migration" + ], + "bin": { + "mcp-codemod": "./dist/cli.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "generate:versions": "tsx scripts/generateVersions.ts", + "generate:spec-schemas": "tsx scripts/generateSpecSchemaMap.ts", + "prebuild": "pnpm run generate:versions && pnpm run generate:spec-schemas", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "batch-test": "tsx src/bin/batchTest.ts", + "batch-test:clean": "rm -rf batch-test/repos batch-test/results batch-test/tarballs", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "commander": "^13.0.0", + "ts-morph": "^28.0.0" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/codemod/scripts/generateSpecSchemaMap.ts b/packages/codemod/scripts/generateSpecSchemaMap.ts new file mode 100644 index 0000000..29796f6 --- /dev/null +++ b/packages/codemod/scripts/generateSpecSchemaMap.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const specTypeSchemaPath = path.resolve(__dirname, '../../core/src/types/specTypeSchema.ts'); + +const source = readFileSync(specTypeSchemaPath, 'utf8'); + +// Extract SPEC_SCHEMA_KEYS array entries +const keysMatch = source.match(/const SPEC_SCHEMA_KEYS = \[([\s\S]*?)\] as const/); +if (!keysMatch) throw new Error('Could not find SPEC_SCHEMA_KEYS in specTypeSchema.ts'); + +const protocolSchemas = [...keysMatch[1]!.matchAll(/'([^']+)'/g)].map(m => m[1]!); + +// Extract auth schema keys +const authMatch = source.match(/const authSchemas = \{([\s\S]*?)\} as const/); +if (!authMatch) throw new Error('Could not find authSchemas in specTypeSchema.ts'); + +const authSchemas = [...authMatch[1]!.matchAll(/(\w+Schema)/g)].map(m => m[1]!); + +const allSchemas = [...protocolSchemas, ...authSchemas].toSorted(); + +const entries = allSchemas.map((s, i) => ` '${s}'${i < allSchemas.length - 1 ? ',' : ''}`).join('\n'); + +const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:spec-schemas\` to regenerate. +export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ +${entries} +]); + +export function specSchemaToTypeName(schemaName: string): string | undefined { + if (!SPEC_SCHEMA_NAMES.has(schemaName)) return undefined; + return schemaName.slice(0, -'Schema'.length); +} +`; + +const outPath = path.resolve(__dirname, '../src/generated/specSchemaMap.ts'); +writeFileSync(outPath, output); +console.log(`Wrote ${outPath} (${allSchemas.length} schemas)`); diff --git a/packages/codemod/scripts/generateVersions.ts b/packages/codemod/scripts/generateVersions.ts new file mode 100644 index 0000000..8a59ba7 --- /dev/null +++ b/packages/codemod/scripts/generateVersions.ts @@ -0,0 +1,34 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packagesDir = path.resolve(__dirname, '../..'); + +const PACKAGE_DIRS: Record = { + '@modelcontextprotocol/client': 'client', + '@modelcontextprotocol/server': 'server', + '@modelcontextprotocol/node': 'middleware/node', + '@modelcontextprotocol/express': 'middleware/express' +}; + +const versions: Record = {}; + +for (const [pkg, dir] of Object.entries(PACKAGE_DIRS)) { + const pkgJsonPath = path.join(packagesDir, dir, 'package.json'); + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + versions[pkg] = `^${pkgJson.version}`; +} + +const entries = Object.entries(versions); +const lines = entries.map(([pkg, ver], i) => ` '${pkg}': '${ver}'${i < entries.length - 1 ? ',' : ''}`).join('\n'); + +const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:versions\` to regenerate. +export const V2_PACKAGE_VERSIONS: Record = { +${lines} +}; +`; + +const outPath = path.resolve(__dirname, '../src/generated/versions.ts'); +writeFileSync(outPath, output); +console.log(`Wrote ${outPath}`); diff --git a/packages/codemod/src/bin/batchTest.ts b/packages/codemod/src/bin/batchTest.ts new file mode 100644 index 0000000..fac4e59 --- /dev/null +++ b/packages/codemod/src/bin/batchTest.ts @@ -0,0 +1,463 @@ +#!/usr/bin/env node + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getMigration } from '../migrations/index.js'; +import { run } from '../runner.js'; +import type { Diagnostic, RunnerResult } from '../types.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface PackageEntry { + dir: string; + sourceDir?: string; + checks?: Record; +} + +interface RepoEntry { + repo: string; + ref?: string; + packages?: PackageEntry[]; +} + +interface CheckResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface PackageReport { + dir: string; + sourceDir: string; + codemod: { + filesChanged: number; + totalChanges: number; + diagnostics: Diagnostic[]; + }; + baseline: Record; + postCodemod: Record; +} + +interface RepoReport { + repo: string; + ref: string; + timestamp: string; + packageManager: string; + packages: PackageReport[]; +} + +interface SummaryEntry { + repo: string; + package: string; + baselineClean: boolean; + postCodemodClean: boolean; + newErrors: Record; + codemodDiagnostics: Record; +} + +interface Summary { + timestamp: string; + codemodVersion: string; + codemodCommit: string; + totalRepos: number; + totalPackages: number; + results: SummaryEntry[]; + aggregated: { + reposClean: number; + reposWithNewErrors: number; + totalNewTypecheckErrors: number; + totalCodemodWarnings: number; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SDK_ROOT = path.resolve(SCRIPT_DIR, '../../../..'); +const BATCH_DIR = path.resolve(SDK_ROOT, 'packages/codemod/batch-test'); + +const LOCAL_PACKAGE_DIRS: Record = { + '@modelcontextprotocol/client': path.join(SDK_ROOT, 'packages/client'), + '@modelcontextprotocol/core': path.join(SDK_ROOT, 'packages/core'), + '@modelcontextprotocol/server': path.join(SDK_ROOT, 'packages/server'), + '@modelcontextprotocol/express': path.join(SDK_ROOT, 'packages/middleware/express'), + '@modelcontextprotocol/fastify': path.join(SDK_ROOT, 'packages/middleware/fastify'), + '@modelcontextprotocol/hono': path.join(SDK_ROOT, 'packages/middleware/hono'), + '@modelcontextprotocol/node': path.join(SDK_ROOT, 'packages/middleware/node') +}; + +const TARBALL_DIR = path.join(BATCH_DIR, 'tarballs'); + +const CHECK_SCRIPT_NAMES: Record = { + typecheck: ['typecheck', 'type-check', 'check:types', 'tsc'], + build: ['build', 'compile'], + test: ['test', 'test:unit', 'test:all'], + lint: ['lint', 'lint:check'] +}; + +function detectPm(repoRoot: string): string { + if (existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm'; + if (existsSync(path.join(repoRoot, 'yarn.lock'))) return 'yarn'; + if (existsSync(path.join(repoRoot, 'bun.lockb'))) return 'bun'; + return 'npm'; +} + +function detectCheckCmd(pkgDir: string, checkType: string): string | null { + const pkgJsonPath = path.join(pkgDir, 'package.json'); + if (!existsSync(pkgJsonPath)) return null; + + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as { scripts?: Record }; + const scripts = pkgJson.scripts ?? {}; + const candidates = CHECK_SCRIPT_NAMES[checkType] ?? []; + + for (const name of candidates) { + if (name in scripts) return name; + } + + if (checkType === 'typecheck') return '__fallback_tsc'; + return null; +} + +function shell(cmd: string, cwd?: string): { exitCode: number; stdout: string; stderr: string } { + try { + const stdout = execSync(cmd, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + timeout: 5 * 60 * 1000 + }).toString(); + return { exitCode: 0, stdout, stderr: '' }; + } catch (error: unknown) { + const e = error as { status?: number; stdout?: Buffer; stderr?: Buffer }; + return { + exitCode: e.status ?? 1, + stdout: e.stdout?.toString() ?? '', + stderr: e.stderr?.toString() ?? '' + }; + } +} + +function runCheck(pm: string, pkgDir: string, checkType: string, override?: string | null): CheckResult { + if (override === null) { + return { exitCode: -1, stdout: '', stderr: 'skipped by manifest' }; + } + + let cmd: string; + if (override) { + cmd = override; + } else { + const detected = detectCheckCmd(pkgDir, checkType); + if (!detected) { + return { exitCode: -1, stdout: '', stderr: 'skipped: no matching script' }; + } + cmd = detected === '__fallback_tsc' ? 'npx tsc --noEmit' : `${pm} run ${detected}`; + } + + return shell(cmd, pkgDir); +} + +function truncate(s: string, max = 50_000): string { + return s.length > max ? s.slice(0, max) + '\n... (truncated)' : s; +} + +function packLocalPackages(): Record { + mkdirSync(TARBALL_DIR, { recursive: true }); + + const tarballs: Record = {}; + for (const [name, pkgDir] of Object.entries(LOCAL_PACKAGE_DIRS)) { + console.log(` Packing ${name}...`); + const result = shell(`pnpm pack --pack-destination ${JSON.stringify(TARBALL_DIR)}`, pkgDir); + if (result.exitCode !== 0) { + console.error(` ERROR: failed to pack ${name}: ${result.stderr.split('\n')[0]}`); + continue; + } + const tarballFile = result.stdout.trim().split('\n').pop()!; + tarballs[name] = path.resolve(TARBALL_DIR, path.basename(tarballFile)); + } + return tarballs; +} + +function rewriteToLocalTarballs(pkgJsonPath: string, tarballs: Record): number { + const raw = readFileSync(pkgJsonPath, 'utf8'); + const pkgJson = JSON.parse(raw) as Record; + let rewrites = 0; + + for (const section of ['dependencies', 'devDependencies']) { + const deps = pkgJson[section] as Record | undefined; + if (!deps) continue; + for (const [name, tarballPath] of Object.entries(tarballs)) { + if (name in deps) { + deps[name] = `file:${tarballPath}`; + rewrites++; + } + } + } + + if (rewrites > 0) { + const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '; + const trailingNewline = raw.endsWith('\n'); + let output = JSON.stringify(pkgJson, null, indent); + if (trailingNewline) output += '\n'; + writeFileSync(pkgJsonPath, output); + } + + return rewrites; +} + +function getCheckOverride(checks: Record, type: string): string | null | undefined { + if (type in checks) return checks[type] ?? null; + return undefined; +} + +function isAllClean(checks: Record): boolean { + return Object.values(checks).every(c => c.exitCode === 0 || c.exitCode === -1); +} + +function hasNewError(baseline: Record, post: Record, type: string): number { + return baseline[type]!.exitCode === 0 && post[type]!.exitCode !== 0 ? 1 : 0; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const CLONE_DIR = path.join(BATCH_DIR, 'repos'); +const OUTPUT_DIR = path.join(BATCH_DIR, 'results'); + +function parseArgs(): { manifest: string } { + const args = process.argv.slice(2).filter(a => a !== '--'); + const opts = { + manifest: path.join(BATCH_DIR, 'repos.json') + }; + + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--manifest': { + opts.manifest = args[++i]!; + break; + } + default: { + console.error(`Unknown flag: ${args[i]}`); + process.exit(1); + } + } + } + return opts; +} + +function main(): void { + const opts = parseArgs(); + + if (!existsSync(opts.manifest)) { + console.error(`Error: manifest not found at ${opts.manifest}`); + process.exit(1); + } + + const migration = getMigration('v1-to-v2'); + if (!migration) { + console.error('Error: v1-to-v2 migration not found'); + process.exit(1); + } + + mkdirSync(CLONE_DIR, { recursive: true }); + mkdirSync(OUTPUT_DIR, { recursive: true }); + + const manifest: RepoEntry[] = JSON.parse(readFileSync(opts.manifest, 'utf8')) as RepoEntry[]; + const codemodPkg = JSON.parse(readFileSync(path.join(SDK_ROOT, 'packages/codemod/package.json'), 'utf8')) as { version: string }; + const codemodVersion = codemodPkg.version; + const codemodCommit = execSync('git rev-parse --short HEAD', { cwd: SDK_ROOT }).toString().trim(); + const timestamp = new Date().toISOString(); + + console.log('=== Codemod Batch Test ==='); + console.log(`Manifest: ${opts.manifest} (${manifest.length} repos)`); + console.log(`Codemod: v${codemodVersion} (${codemodCommit})`); + console.log(''); + + console.log('--- Packing local SDK packages ---'); + const tarballs = packLocalPackages(); + console.log(` Packed ${Object.keys(tarballs).length} packages\n`); + + const summaryResults: SummaryEntry[] = []; + let totalPackages = 0; + + for (let i = 0; i < manifest.length; i++) { + const entry = manifest[i]!; + const ref = entry.ref ?? 'main'; + const repoSlug = entry.repo.replace('/', '_'); + const clonePath = path.join(CLONE_DIR, repoSlug); + + console.log(`--- [${i + 1}/${manifest.length}] ${entry.repo} (${ref}) ---`); + + // Step 1: Clone or reset + if (existsSync(path.join(clonePath, '.git'))) { + console.log(' Resetting existing clone...'); + shell('git restore .', clonePath); + shell('git clean -fd', clonePath); + } else { + console.log(' Cloning...'); + const cloneResult = shell( + `git clone --depth 1 --branch ${ref} https://github.com/${entry.repo}.git ${JSON.stringify(clonePath)}` + ); + if (cloneResult.exitCode !== 0) { + console.log(` ERROR: clone failed, skipping\n ${cloneResult.stderr.split('\n')[0]}`); + continue; + } + } + + // Step 2: Detect package manager + const pm = detectPm(clonePath); + console.log(` Package manager: ${pm}`); + + // Step 3: Install + console.log(' Installing dependencies...'); + const installResult = shell(`${pm} install --ignore-scripts`, clonePath); + if (installResult.exitCode !== 0) { + console.log(` ERROR: install failed, skipping\n ${installResult.stderr.split('\n')[0]}`); + continue; + } + + // Process packages + const packages: PackageEntry[] = entry.packages ?? [{ dir: '.', sourceDir: 'src' }]; + const repoPkgResults: PackageReport[] = []; + + for (const pkg of packages) { + const sourceDir = pkg.sourceDir ?? 'src'; + const fullPkgDir = path.join(clonePath, pkg.dir); + const fullSourceDir = path.join(fullPkgDir, sourceDir); + + console.log(` Package: ${pkg.dir} (source: ${sourceDir})`); + + const checks = pkg.checks ?? {}; + + // Step 4: Baseline checks + console.log(' Running baseline checks...'); + const baseline: Record = {}; + for (const checkType of ['typecheck', 'build', 'test', 'lint']) { + baseline[checkType] = runCheck(pm, fullPkgDir, checkType, getCheckOverride(checks, checkType)); + } + console.log( + ` Baseline: tc=${baseline['typecheck']!.exitCode} build=${baseline['build']!.exitCode} test=${baseline['test']!.exitCode} lint=${baseline['lint']!.exitCode}` + ); + + // Step 5: Run codemod (programmatic API) + console.log(' Running codemod...'); + let codemodResult: RunnerResult; + try { + codemodResult = run(migration, { targetDir: fullSourceDir, verbose: true }); + } catch (error) { + console.log(` ERROR: codemod threw: ${error}`); + codemodResult = { filesChanged: 0, totalChanges: 0, diagnostics: [], fileResults: [] }; + } + console.log( + ` Codemod: files=${codemodResult.filesChanged} changes=${codemodResult.totalChanges} diags=${codemodResult.diagnostics.length}` + ); + + // Step 6: Rewrite v2 deps to local tarballs, then re-install + const rewrites = rewriteToLocalTarballs(path.join(fullPkgDir, 'package.json'), tarballs); + if (rewrites > 0) { + console.log(` Rewrote ${rewrites} deps to local tarballs`); + } + console.log(' Re-installing dependencies...'); + shell(`${pm} install --ignore-scripts`, clonePath); + + // Step 7: Post-codemod checks + console.log(' Running post-codemod checks...'); + const postCodemod: Record = {}; + for (const checkType of ['typecheck', 'build', 'test', 'lint']) { + postCodemod[checkType] = runCheck(pm, fullPkgDir, checkType, getCheckOverride(checks, checkType)); + } + console.log( + ` Post: tc=${postCodemod['typecheck']!.exitCode} build=${postCodemod['build']!.exitCode} test=${postCodemod['test']!.exitCode} lint=${postCodemod['lint']!.exitCode}` + ); + + // Truncate large outputs for the report + for (const r of [...Object.values(baseline), ...Object.values(postCodemod)]) { + r.stdout = truncate(r.stdout); + r.stderr = truncate(r.stderr); + } + + repoPkgResults.push({ + dir: pkg.dir, + sourceDir, + codemod: { + filesChanged: codemodResult.filesChanged, + totalChanges: codemodResult.totalChanges, + diagnostics: codemodResult.diagnostics + }, + baseline, + postCodemod + }); + totalPackages++; + + summaryResults.push({ + repo: entry.repo, + package: pkg.dir, + baselineClean: isAllClean(baseline), + postCodemodClean: isAllClean(postCodemod), + newErrors: { + typecheck: hasNewError(baseline, postCodemod, 'typecheck'), + build: hasNewError(baseline, postCodemod, 'build'), + test: hasNewError(baseline, postCodemod, 'test'), + lint: hasNewError(baseline, postCodemod, 'lint') + }, + codemodDiagnostics: { + warning: codemodResult.diagnostics.filter(d => d.level === 'warning').length, + error: codemodResult.diagnostics.filter(d => d.level === 'error').length, + info: codemodResult.diagnostics.filter(d => d.level === 'info').length + } + }); + } + + // Step 8: Write per-repo report + const repoOutputDir = path.join(OUTPUT_DIR, repoSlug); + mkdirSync(repoOutputDir, { recursive: true }); + + const report: RepoReport = { + repo: entry.repo, + ref, + timestamp, + packageManager: pm, + packages: repoPkgResults + }; + writeFileSync(path.join(repoOutputDir, 'report.json'), JSON.stringify(report, null, 2)); + console.log(` Report: ${repoOutputDir}/report.json\n`); + } + + // Write summary + const reposClean = summaryResults.filter(r => r.postCodemodClean).length; + const reposWithErrors = summaryResults.filter(r => !r.postCodemodClean).length; + const totalNewTc = summaryResults.reduce((sum, r) => sum + r.newErrors['typecheck']!, 0); + const totalWarnings = summaryResults.reduce((sum, r) => sum + r.codemodDiagnostics['warning']!, 0); + + const summary: Summary = { + timestamp, + codemodVersion, + codemodCommit, + totalRepos: manifest.length, + totalPackages, + results: summaryResults, + aggregated: { + reposClean, + reposWithNewErrors: reposWithErrors, + totalNewTypecheckErrors: totalNewTc, + totalCodemodWarnings: totalWarnings + } + }; + writeFileSync(path.join(OUTPUT_DIR, 'summary.json'), JSON.stringify(summary, null, 2)); + + console.log('=== Summary ==='); + console.log(`Repos: ${manifest.length} | Packages: ${totalPackages}`); + console.log(`Clean after codemod: ${reposClean} | With new errors: ${reposWithErrors}`); + console.log(`New typecheck errors: ${totalNewTc} | Codemod warnings: ${totalWarnings}`); + console.log(''); + console.log(`Results: ${OUTPUT_DIR}/summary.json`); +} + +main(); diff --git a/packages/codemod/src/cli.ts b/packages/codemod/src/cli.ts new file mode 100644 index 0000000..d143a71 --- /dev/null +++ b/packages/codemod/src/cli.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +import { existsSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { Command } from 'commander'; + +import { listMigrations } from './migrations/index.js'; +import { run } from './runner.js'; +import { DiagnosticLevel } from './types.js'; +import { formatDiagnostic } from './utils/diagnostics.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../package.json') as { version: string }; + +const program = new Command(); + +program.name('mcp-codemod').description('Codemod to migrate MCP TypeScript SDK code between versions').version(version); + +for (const [name, migration] of listMigrations()) { + program + .command(`${name} [target-dir]`) + .description(migration.description) + .option('-d, --dry-run', 'Preview changes without writing files') + .option('-t, --transforms ', 'Comma-separated transform IDs to run (default: all)') + .option('-v, --verbose', 'Show detailed per-change output') + .option('--ignore ', 'Additional glob patterns to ignore') + .option('--list', 'List available transforms for this migration') + .action((targetDir: string | undefined, opts: Record) => { + try { + if (opts['list']) { + console.log(`\nAvailable transforms for ${name}:\n`); + for (const t of migration.transforms) { + console.log(` ${t.id.padEnd(20)} ${t.name}`); + } + console.log(''); + return; + } + + if (!targetDir) { + console.error(`\nError: missing required argument .\n`); + process.exitCode = 1; + return; + } + + const resolvedDir = path.resolve(targetDir); + + if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) { + console.error(`\nError: "${resolvedDir}" is not a valid directory.\n`); + process.exitCode = 1; + return; + } + + console.log(`\n@modelcontextprotocol/codemod — ${migration.name}\n`); + console.log(`Scanning ${resolvedDir}...`); + if (opts['dryRun']) { + console.log('(dry run — no files will be modified)\n'); + } else { + console.log(''); + } + + const transforms = opts['transforms'] ? (opts['transforms'] as string).split(',').map(s => s.trim()) : undefined; + + const result = run(migration, { + targetDir: resolvedDir, + dryRun: opts['dryRun'] as boolean | undefined, + verbose: opts['verbose'] as boolean | undefined, + transforms, + ignore: opts['ignore'] as string[] | undefined + }); + + if (result.filesChanged === 0 && result.diagnostics.length === 0 && !result.packageJsonChanges) { + console.log('No changes needed — code already migrated or no SDK imports found.\n'); + return; + } + + if (result.filesChanged > 0) { + console.log(`Changes: ${result.totalChanges} across ${result.filesChanged} file(s)\n`); + } + + if (opts['verbose']) { + console.log('Files modified:'); + for (const fr of result.fileResults) { + console.log(` ${fr.filePath} (${fr.changes} change(s))`); + } + console.log(''); + } + + const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + if (errors.length > 0) { + console.log(`Errors (${errors.length}):`); + for (const d of errors) { + console.log(formatDiagnostic(d)); + } + console.log(''); + process.exitCode = 1; + } + + const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning && d.category !== 'v2-gap'); + if (warnings.length > 0) { + console.log(`Warnings (${warnings.length}):`); + for (const d of warnings) { + console.log(formatDiagnostic(d)); + } + console.log(''); + } + + const infos = result.diagnostics.filter(d => d.level === DiagnosticLevel.Info); + if (infos.length > 0) { + console.log(`Info (${infos.length}):`); + for (const d of infos) { + console.log(formatDiagnostic(d)); + } + console.log(''); + } + + const v2Gaps = result.diagnostics.filter(d => d.category === 'v2-gap'); + if (v2Gaps.length > 0) { + console.log(`SDK v2 known issues (${v2Gaps.length}):`); + for (const d of v2Gaps) { + console.log(formatDiagnostic(d)); + } + console.log(''); + } + + if (result.packageJsonChanges) { + const pc = result.packageJsonChanges; + if (opts['dryRun']) { + console.log('package.json changes (dry run — not applied):'); + } else { + console.log('package.json updated:'); + } + if (pc.removed.length > 0) { + console.log(` Removed: ${pc.removed.join(', ')}`); + } + if (pc.added.length > 0) { + console.log(` Added: ${pc.added.join(', ')}`); + } + console.log(''); + } + + if (opts['dryRun']) { + console.log('Run without --dry-run to apply changes.\n'); + } else { + if (result.packageJsonChanges) { + console.log('Run your package manager to install the new packages.\n'); + } + console.log('Migration complete. Review the changes and run your build/tests.\n'); + } + } catch (error) { + console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } + }); +} + +program.parse(); diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts new file mode 100644 index 0000000..2536456 --- /dev/null +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -0,0 +1,170 @@ +// AUTO-GENERATED — do not edit. Run `pnpm run generate:spec-schemas` to regenerate. +export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ + 'AnnotationsSchema', + 'AudioContentSchema', + 'BaseMetadataSchema', + 'BlobResourceContentsSchema', + 'BooleanSchemaSchema', + 'CallToolRequestParamsSchema', + 'CallToolRequestSchema', + 'CallToolResultSchema', + 'CancelTaskRequestSchema', + 'CancelTaskResultSchema', + 'CancelledNotificationParamsSchema', + 'CancelledNotificationSchema', + 'ClientCapabilitiesSchema', + 'ClientNotificationSchema', + 'ClientRequestSchema', + 'ClientResultSchema', + 'CompatibilityCallToolResultSchema', + 'CompleteRequestParamsSchema', + 'CompleteRequestSchema', + 'CompleteResultSchema', + 'ContentBlockSchema', + 'CreateMessageRequestParamsSchema', + 'CreateMessageRequestSchema', + 'CreateMessageResultSchema', + 'CreateMessageResultWithToolsSchema', + 'CreateTaskResultSchema', + 'CursorSchema', + 'ElicitRequestFormParamsSchema', + 'ElicitRequestParamsSchema', + 'ElicitRequestSchema', + 'ElicitRequestURLParamsSchema', + 'ElicitResultSchema', + 'ElicitationCompleteNotificationParamsSchema', + 'ElicitationCompleteNotificationSchema', + 'EmbeddedResourceSchema', + 'EmptyResultSchema', + 'EnumSchemaSchema', + 'GetPromptRequestParamsSchema', + 'GetPromptRequestSchema', + 'GetPromptResultSchema', + 'GetTaskPayloadRequestSchema', + 'GetTaskPayloadResultSchema', + 'GetTaskRequestSchema', + 'GetTaskResultSchema', + 'IconSchema', + 'IconsSchema', + 'ImageContentSchema', + 'ImplementationSchema', + 'InitializeRequestParamsSchema', + 'InitializeRequestSchema', + 'InitializeResultSchema', + 'InitializedNotificationSchema', + 'JSONArraySchema', + 'JSONObjectSchema', + 'JSONRPCErrorResponseSchema', + 'JSONRPCMessageSchema', + 'JSONRPCNotificationSchema', + 'JSONRPCRequestSchema', + 'JSONRPCResponseSchema', + 'JSONRPCResultResponseSchema', + 'JSONValueSchema', + 'LegacyTitledEnumSchemaSchema', + 'ListPromptsRequestSchema', + 'ListPromptsResultSchema', + 'ListResourceTemplatesRequestSchema', + 'ListResourceTemplatesResultSchema', + 'ListResourcesRequestSchema', + 'ListResourcesResultSchema', + 'ListRootsRequestSchema', + 'ListRootsResultSchema', + 'ListTasksRequestSchema', + 'ListTasksResultSchema', + 'ListToolsRequestSchema', + 'ListToolsResultSchema', + 'LoggingLevelSchema', + 'LoggingMessageNotificationParamsSchema', + 'LoggingMessageNotificationSchema', + 'ModelHintSchema', + 'ModelPreferencesSchema', + 'MultiSelectEnumSchemaSchema', + 'NotificationSchema', + 'NumberSchemaSchema', + 'OAuthClientInformationFullSchema', + 'OAuthClientInformationSchema', + 'OAuthClientMetadataSchema', + 'OAuthClientRegistrationErrorSchema', + 'OAuthErrorResponseSchema', + 'OAuthMetadataSchema', + 'OAuthProtectedResourceMetadataSchema', + 'OAuthTokenRevocationRequestSchema', + 'OAuthTokensSchema', + 'OpenIdProviderDiscoveryMetadataSchema', + 'OpenIdProviderMetadataSchema', + 'PaginatedRequestParamsSchema', + 'PaginatedRequestSchema', + 'PaginatedResultSchema', + 'PingRequestSchema', + 'PrimitiveSchemaDefinitionSchema', + 'ProgressNotificationParamsSchema', + 'ProgressNotificationSchema', + 'ProgressSchema', + 'ProgressTokenSchema', + 'PromptArgumentSchema', + 'PromptListChangedNotificationSchema', + 'PromptMessageSchema', + 'PromptReferenceSchema', + 'PromptSchema', + 'ReadResourceRequestParamsSchema', + 'ReadResourceRequestSchema', + 'ReadResourceResultSchema', + 'RelatedTaskMetadataSchema', + 'RequestIdSchema', + 'RequestMetaSchema', + 'RequestSchema', + 'ResourceContentsSchema', + 'ResourceLinkSchema', + 'ResourceListChangedNotificationSchema', + 'ResourceRequestParamsSchema', + 'ResourceSchema', + 'ResourceTemplateReferenceSchema', + 'ResourceTemplateSchema', + 'ResourceUpdatedNotificationParamsSchema', + 'ResourceUpdatedNotificationSchema', + 'ResultSchema', + 'RoleSchema', + 'RootSchema', + 'RootsListChangedNotificationSchema', + 'SamplingContentSchema', + 'SamplingMessageContentBlockSchema', + 'SamplingMessageSchema', + 'ServerCapabilitiesSchema', + 'ServerNotificationSchema', + 'ServerRequestSchema', + 'ServerResultSchema', + 'SetLevelRequestParamsSchema', + 'SetLevelRequestSchema', + 'SingleSelectEnumSchemaSchema', + 'StringSchemaSchema', + 'SubscribeRequestParamsSchema', + 'SubscribeRequestSchema', + 'TaskAugmentedRequestParamsSchema', + 'TaskCreationParamsSchema', + 'TaskMetadataSchema', + 'TaskSchema', + 'TaskStatusNotificationParamsSchema', + 'TaskStatusNotificationSchema', + 'TaskStatusSchema', + 'TextContentSchema', + 'TextResourceContentsSchema', + 'TitledMultiSelectEnumSchemaSchema', + 'TitledSingleSelectEnumSchemaSchema', + 'ToolAnnotationsSchema', + 'ToolChoiceSchema', + 'ToolExecutionSchema', + 'ToolListChangedNotificationSchema', + 'ToolResultContentSchema', + 'ToolSchema', + 'ToolUseContentSchema', + 'UnsubscribeRequestParamsSchema', + 'UnsubscribeRequestSchema', + 'UntitledMultiSelectEnumSchemaSchema', + 'UntitledSingleSelectEnumSchemaSchema' +]); + +export function specSchemaToTypeName(schemaName: string): string | undefined { + if (!SPEC_SCHEMA_NAMES.has(schemaName)) return undefined; + return schemaName.slice(0, -'Schema'.length); +} diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts new file mode 100644 index 0000000..b9fba64 --- /dev/null +++ b/packages/codemod/src/generated/versions.ts @@ -0,0 +1,7 @@ +// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. +export const V2_PACKAGE_VERSIONS: Record = { + '@modelcontextprotocol/client': '^2.0.0-alpha.2', + '@modelcontextprotocol/server': '^2.0.0-alpha.2', + '@modelcontextprotocol/node': '^2.0.0-alpha.2', + '@modelcontextprotocol/express': '^2.0.0-alpha.2' +}; diff --git a/packages/codemod/src/index.ts b/packages/codemod/src/index.ts new file mode 100644 index 0000000..724cd26 --- /dev/null +++ b/packages/codemod/src/index.ts @@ -0,0 +1,14 @@ +export { getMigration, listMigrations } from './migrations/index.js'; +export { run } from './runner.js'; +export type { + Diagnostic, + FileResult, + Migration, + PackageJsonChange, + RunnerOptions, + RunnerResult, + Transform, + TransformContext, + TransformResult +} from './types.js'; +export { DiagnosticLevel } from './types.js'; diff --git a/packages/codemod/src/migrations/index.ts b/packages/codemod/src/migrations/index.ts new file mode 100644 index 0000000..1630393 --- /dev/null +++ b/packages/codemod/src/migrations/index.ts @@ -0,0 +1,12 @@ +import type { Migration } from '../types.js'; +import { v1ToV2Migration } from './v1-to-v2/index.js'; + +const migrations = new Map([['v1-to-v2', v1ToV2Migration]]); + +export function getMigration(name: string): Migration | undefined { + return migrations.get(name); +} + +export function listMigrations(): Map { + return migrations; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/index.ts b/packages/codemod/src/migrations/v1-to-v2/index.ts new file mode 100644 index 0000000..689331a --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/index.ts @@ -0,0 +1,8 @@ +import type { Migration } from '../../types.js'; +import { v1ToV2Transforms } from './transforms/index.js'; + +export const v1ToV2Migration: Migration = { + name: 'v1-to-v2', + description: 'Migrate from @modelcontextprotocol/sdk (v1) to v2 packages (@modelcontextprotocol/client, /server, etc.)', + transforms: v1ToV2Transforms +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts new file mode 100644 index 0000000..b514d53 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts @@ -0,0 +1,23 @@ +export interface ContextMapping { + from: string; + to: string; +} + +export const CONTEXT_PROPERTY_MAP: ContextMapping[] = [ + { from: '.signal', to: '.mcpReq.signal' }, + { from: '.requestId', to: '.mcpReq.id' }, + { from: '._meta', to: '.mcpReq._meta' }, + { from: '.sendRequest', to: '.mcpReq.send' }, + { from: '.sendNotification', to: '.mcpReq.notify' }, + { from: '.authInfo', to: '.http?.authInfo' }, + { from: '.sessionId', to: '.sessionId' }, + { from: '.requestInfo', to: '.http?.req' }, + { from: '.closeSSEStream', to: '.http?.closeSSE' }, + { from: '.closeStandaloneSSEStream', to: '.http?.closeStandaloneSSE' }, + { from: '.taskStore', to: '.task?.store' }, + { from: '.taskId', to: '.task?.id' }, + { from: '.taskRequestedTtl', to: '.task?.requestedTtl' } +]; + +export const EXTRA_PARAM_NAME = 'extra'; +export const CTX_PARAM_NAME = 'ctx'; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts new file mode 100644 index 0000000..bc69faf --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts @@ -0,0 +1,168 @@ +export interface ImportMapping { + target: string; + status: 'moved' | 'removed' | 'renamed'; + renamedSymbols?: Record; + /** Route specific symbols to a different target package than `target`. */ + symbolTargetOverrides?: Record; + removalMessage?: string; + /** No entries currently set this; scaffolding for when a v1 symbol has no v2 equivalent yet. */ + isV2Gap?: boolean; +} + +export const IMPORT_MAP: Record = { + '@modelcontextprotocol/sdk/client/index.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/auth.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/streamableHttp.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/sse.js': { + target: '@modelcontextprotocol/client', + status: 'moved' + }, + '@modelcontextprotocol/sdk/client/stdio.js': { + target: '@modelcontextprotocol/client', + status: 'moved', + symbolTargetOverrides: { + StdioClientTransport: '@modelcontextprotocol/client/stdio', + DEFAULT_INHERITED_ENV_VARS: '@modelcontextprotocol/client/stdio', + getDefaultEnvironment: '@modelcontextprotocol/client/stdio', + StdioServerParameters: '@modelcontextprotocol/client/stdio' + } + }, + '@modelcontextprotocol/sdk/client/websocket.js': { + target: '', + status: 'removed', + removalMessage: 'WebSocketClientTransport removed in v2. Use StreamableHTTPClientTransport or StdioClientTransport.' + }, + + '@modelcontextprotocol/sdk/server/mcp.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/index.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/stdio.js': { + target: '@modelcontextprotocol/server/stdio', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/streamableHttp.js': { + target: '@modelcontextprotocol/server', + status: 'renamed', + renamedSymbols: { + StreamableHTTPServerTransport: 'NodeStreamableHTTPServerTransport' + }, + symbolTargetOverrides: { + StreamableHTTPServerTransport: '@modelcontextprotocol/node' + } + }, + '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/sse.js': { + target: '', + status: 'removed', + removalMessage: 'SSE server transport removed in v2. Migrate to NodeStreamableHTTPServerTransport from @modelcontextprotocol/node.' + }, + '@modelcontextprotocol/sdk/server/middleware.js': { + target: '@modelcontextprotocol/express', + status: 'moved' + }, + '@modelcontextprotocol/sdk/server/zod-compat.js': { + target: '', + status: 'removed', + removalMessage: + 'zod-compat removed in v2. AnySchema and SchemaOutput types have no v2 equivalent — v2 uses StandardSchemaV1 from @standard-schema/spec. Rewrite generic function signatures to use StandardSchemaV1 directly.' + }, + + '@modelcontextprotocol/sdk/server/auth/types.js': { + target: '', + status: 'removed', + removalMessage: + 'Server auth removed in v2. AuthInfo is now re-exported by @modelcontextprotocol/client and @modelcontextprotocol/server.' + }, + '@modelcontextprotocol/sdk/server/auth/provider.js': { + target: '', + status: 'removed', + removalMessage: + 'Server auth provider removed in v2. For Resource-Server auth (token verification), see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).' + }, + '@modelcontextprotocol/sdk/server/auth/router.js': { + target: '', + status: 'removed', + removalMessage: + 'Server auth router removed in v2. For metadata endpoints, see mcpAuthMetadataRouter from @modelcontextprotocol/express. For full OAuth AS router, see @modelcontextprotocol/server-auth-legacy (PR #1908).' + }, + '@modelcontextprotocol/sdk/server/auth/middleware.js': { + target: '', + status: 'removed', + removalMessage: + 'Server auth middleware removed in v2. For bearer token validation, see requireBearerAuth from @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).' + }, + '@modelcontextprotocol/sdk/server/auth/errors.js': { + target: '', + status: 'removed', + removalMessage: + 'Auth error subclasses consolidated in v2. Use OAuthError + OAuthErrorCode from @modelcontextprotocol/server. See also @modelcontextprotocol/server-auth-legacy (PR #1908).' + }, + + '@modelcontextprotocol/sdk/types.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved', + renamedSymbols: { + ResourceTemplate: 'ResourceTemplateType' + } + }, + '@modelcontextprotocol/sdk/shared/protocol.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/transport.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/uriTemplate.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/auth.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + '@modelcontextprotocol/sdk/shared/stdio.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/server/completable.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/experimental/tasks': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + '@modelcontextprotocol/sdk/experimental/tasks.js': { + target: '@modelcontextprotocol/server', + status: 'moved' + }, + + '@modelcontextprotocol/sdk/inMemory.js': { + target: 'RESOLVE_BY_CONTEXT', + status: 'moved' + } +}; + +export function isAuthImport(specifier: string): boolean { + return specifier.includes('/server/auth/') || specifier.includes('/server/auth.'); +} diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts new file mode 100644 index 0000000..38cdde1 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts @@ -0,0 +1,36 @@ +export const SCHEMA_TO_METHOD: Record = { + InitializeRequestSchema: 'initialize', + CallToolRequestSchema: 'tools/call', + ListToolsRequestSchema: 'tools/list', + ListPromptsRequestSchema: 'prompts/list', + GetPromptRequestSchema: 'prompts/get', + ListResourcesRequestSchema: 'resources/list', + ReadResourceRequestSchema: 'resources/read', + ListResourceTemplatesRequestSchema: 'resources/templates/list', + SubscribeRequestSchema: 'resources/subscribe', + UnsubscribeRequestSchema: 'resources/unsubscribe', + CreateMessageRequestSchema: 'sampling/createMessage', + ElicitRequestSchema: 'elicitation/create', + SetLevelRequestSchema: 'logging/setLevel', + PingRequestSchema: 'ping', + CompleteRequestSchema: 'completion/complete', + ListRootsRequestSchema: 'roots/list', + ListTasksRequestSchema: 'tasks/list', + GetTaskRequestSchema: 'tasks/get', + GetTaskPayloadRequestSchema: 'tasks/result', + CancelTaskRequestSchema: 'tasks/cancel' +}; + +export const NOTIFICATION_SCHEMA_TO_METHOD: Record = { + LoggingMessageNotificationSchema: 'notifications/message', + ToolListChangedNotificationSchema: 'notifications/tools/list_changed', + ResourceListChangedNotificationSchema: 'notifications/resources/list_changed', + PromptListChangedNotificationSchema: 'notifications/prompts/list_changed', + ResourceUpdatedNotificationSchema: 'notifications/resources/updated', + ProgressNotificationSchema: 'notifications/progress', + CancelledNotificationSchema: 'notifications/cancelled', + InitializedNotificationSchema: 'notifications/initialized', + RootsListChangedNotificationSchema: 'notifications/roots/list_changed', + TaskStatusNotificationSchema: 'notifications/tasks/status', + ElicitationCompleteNotificationSchema: 'notifications/elicitation/complete' +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts new file mode 100644 index 0000000..7671a3e --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts @@ -0,0 +1,11 @@ +export const SIMPLE_RENAMES: Record = { + McpError: 'ProtocolError', + JSONRPCError: 'JSONRPCErrorResponse', + JSONRPCErrorSchema: 'JSONRPCErrorResponseSchema', + isJSONRPCError: 'isJSONRPCErrorResponse', + isJSONRPCResponse: 'isJSONRPCResultResponse', + ResourceReference: 'ResourceTemplateReference', + ResourceReferenceSchema: 'ResourceTemplateReferenceSchema' +}; + +export const ERROR_CODE_SDK_MEMBERS = new Set(['RequestTimeout', 'ConnectionClosed']); diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts new file mode 100644 index 0000000..5fb0d2f --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/contextTypes.ts @@ -0,0 +1,257 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { info, warning } from '../../../utils/diagnostics.js'; +import { hasMcpImports } from '../../../utils/importUtils.js'; +import { CONTEXT_PROPERTY_MAP, CTX_PARAM_NAME, EXTRA_PARAM_NAME } from '../mappings/contextPropertyMap.js'; + +const HANDLER_METHODS = new Set(['setRequestHandler', 'setNotificationHandler']); + +const REGISTER_METHODS = new Set(['registerTool', 'registerPrompt', 'registerResource', 'registerToolTask', 'tool', 'prompt', 'resource']); + +/** + * Attempt to rename the second parameter of a callback from 'extra' to 'ctx' + * and rewrite context property accesses in its body. + * Returns the number of changes made, or -1 if skipped. + */ +function processCallback( + callbackNode: Node, + sourceFile: SourceFile, + diagnostics: Diagnostic[], + methodName: string, + callLine: number +): number { + if (!Node.isArrowFunction(callbackNode) && !Node.isFunctionExpression(callbackNode) && !Node.isMethodDeclaration(callbackNode)) + return -1; + + const params = callbackNode.getParameters(); + if (params.length < 2) return -1; + + const extraParam = params[1]!; + const paramNameNode = extraParam.getNameNode(); + if (Node.isObjectBindingPattern(paramNameNode)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Destructuring of context parameter in signature: "${paramNameNode.getText()}". ` + + 'Properties have been reorganized in v2 (e.g., signal is now ctx.mcpReq.signal). Manual refactoring required.' + ) + ); + return -1; + } + const paramName = extraParam.getName(); + if (paramName !== EXTRA_PARAM_NAME) return -1; + + const body = callbackNode.getBody(); + + const otherParams = callbackNode.getParameters().filter(p => p !== extraParam); + if (otherParams.some(p => p.getName() === CTX_PARAM_NAME)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Cannot rename '${EXTRA_PARAM_NAME}' to '${CTX_PARAM_NAME}': another parameter is already named '${CTX_PARAM_NAME}'. Manual migration required.` + ) + ); + return -1; + } + + if (body) { + let ctxAlreadyInScope = false; + body.forEachDescendant((node, traversal) => { + if ( + (Node.isArrowFunction(node) || Node.isFunctionExpression(node) || Node.isFunctionDeclaration(node)) && + node.getParameters().some(p => p.getName() === CTX_PARAM_NAME) + ) { + traversal.skip(); + return; + } + if (Node.isIdentifier(node) && node.getText() === CTX_PARAM_NAME) { + ctxAlreadyInScope = true; + } + }); + if (ctxAlreadyInScope) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraParam.getStartLineNumber(), + `Cannot rename '${EXTRA_PARAM_NAME}' to '${CTX_PARAM_NAME}': '${CTX_PARAM_NAME}' is already referenced in this scope. Manual migration required.` + ) + ); + return -1; + } + } + + // Rename param declaration and rewrite body references using AST traversal. + // We walk Identifier nodes to avoid corrupting string literals, comments, and + // unrelated property names (e.g., meta.extra) that regex-based replacement would hit. + const paramDecl = extraParam.getNameNode(); + paramDecl.replaceWithText(CTX_PARAM_NAME); + + if (body) { + const sortedMappings = [...CONTEXT_PROPERTY_MAP].filter(m => m.from !== m.to).toSorted((a, b) => b.from.length - a.from.length); + + // Collect identifiers that are actual references to the `extra` parameter + const identifiers: import('ts-morph').Node[] = []; + body.forEachDescendant(node => { + if (!Node.isIdentifier(node) || node.getText() !== EXTRA_PARAM_NAME) return; + const parent = node.getParent(); + // Skip property-name positions (e.g., meta.extra, { extra: value }, { extra }, { extra: x } = obj) + if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; + if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; + if (parent && Node.isShorthandPropertyAssignment(parent)) return; + if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return; + identifiers.push(node); + }); + + // Build replacements: apply property mappings for PropertyAccess/QualifiedName, plain rename otherwise + const replacements: { node: import('ts-morph').Node; newText: string }[] = []; + for (const id of identifiers) { + const parent = id.getParent(); + // Value-position property access: extra.signal → ctx.mcpReq.signal + if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === id) { + const propName = '.' + parent.getName(); + const mapping = sortedMappings.find(m => m.from === propName); + if (mapping) { + replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to }); + continue; + } + } + // Type-position qualified name: typeof extra.signal → typeof ctx.mcpReq.signal + if (parent && parent.getKind() === SyntaxKind.QualifiedName && parent.getChildAtIndex(0) === id) { + const right = parent.getChildAtIndex(2); + if (right) { + const propName = '.' + right.getText(); + const mapping = sortedMappings.find(m => m.from === propName); + if (mapping) { + replacements.push({ node: parent, newText: CTX_PARAM_NAME + mapping.to }); + continue; + } + } + } + replacements.push({ node: id, newText: CTX_PARAM_NAME }); + } + + // Apply in reverse position order to avoid node invalidation + const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); + for (const { node, newText } of sorted) { + node.replaceWithText(newText); + } + } + + const changes = 1; + + if (['tool', 'prompt', 'resource'].includes(methodName)) { + diagnostics.push( + info( + sourceFile.getFilePath(), + callLine, + `Renamed 'extra' to 'ctx' in .${methodName}() callback. If this is not an McpServer method, revert this change.` + ) + ); + } + + // Warn on destructuring of ctx in body (after text replacement) + const freshBody = callbackNode.getBody(); + if (freshBody) { + freshBody.forEachDescendant(node => { + if (!Node.isVariableDeclaration(node)) return; + const initializer = node.getInitializer(); + if (!initializer || !Node.isIdentifier(initializer) || initializer.getText() !== CTX_PARAM_NAME) return; + const nameNode = node.getNameNode(); + if (!Node.isObjectBindingPattern(nameNode)) return; + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Destructuring of context parameter detected: "const ${nameNode.getText()} = ${CTX_PARAM_NAME}". ` + + 'Properties have been reorganized in v2 (e.g., signal is now ctx.mcpReq.signal). Manual refactoring required.' + ) + ); + }); + } + + return changes; +} + +export const contextTypesTransform: Transform = { + name: 'Context type rewrites', + id: 'context', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + if (!hasMcpImports(sourceFile)) { + return { changesCount: 0, diagnostics: [] }; + } + + let changesCount = 0; + const diagnostics: Diagnostic[] = []; + + // Process one callback at a time, re-querying the AST after each. + // processCallback uses body.replaceWithText() which invalidates sibling nodes, + // so we cannot iterate a pre-collected list of calls. + let madeProgress = true; + const processed = new Set(); + while (madeProgress) { + madeProgress = false; + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const callStart = call.getStart(); + if (processed.has(callStart)) continue; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + const isHandler = HANDLER_METHODS.has(methodName); + const isRegister = REGISTER_METHODS.has(methodName); + if (!isHandler && !isRegister) continue; + + const args = call.getArguments(); + + let callbackArg: Node | undefined; + if (isHandler && args.length >= 2) { + callbackArg = args[1]; + } else if (isRegister && args.length >= 2) { + callbackArg = args.at(-1); + } + + if (!callbackArg) continue; + + // Handle ObjectLiteralExpression for registerToolTask-style callbacks + if (Node.isObjectLiteralExpression(callbackArg)) { + for (const prop of callbackArg.getProperties()) { + let callbackNode: Node | undefined; + if (Node.isPropertyAssignment(prop)) { + callbackNode = prop.getInitializer(); + } else if (Node.isMethodDeclaration(prop)) { + callbackNode = prop; + } + if (!callbackNode) continue; + + const result = processCallback(callbackNode, sourceFile, diagnostics, methodName, call.getStartLineNumber()); + if (result > 0) { + changesCount += result; + madeProgress = true; + } + } + processed.add(callStart); + if (madeProgress) break; + continue; + } + + // Handle direct ArrowFunction / FunctionExpression callbacks + const result = processCallback(callbackArg, sourceFile, diagnostics, methodName, call.getStartLineNumber()); + processed.add(callStart); + if (result > 0) { + changesCount += result; + madeProgress = true; + break; + } + } + } + + return { changesCount, diagnostics }; + } +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts new file mode 100644 index 0000000..3194610 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/expressMiddleware.ts @@ -0,0 +1,61 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { info } from '../../../utils/diagnostics.js'; +import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils.js'; + +export const expressMiddlewareTransform: Transform = { + name: 'Express middleware signature migration', + id: 'express-middleware', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + if (!isOriginalNameImportedFromMcp(sourceFile, 'hostHeaderValidation')) { + return { changesCount: 0, diagnostics: [] }; + } + + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + const localName = resolveLocalImportName(sourceFile, 'hostHeaderValidation') ?? 'hostHeaderValidation'; + changesCount += rewriteHostHeaderValidation(sourceFile, localName, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function rewriteHostHeaderValidation(sourceFile: SourceFile, targetName: string, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== targetName) continue; + + const args = call.getArguments(); + if (args.length !== 1) continue; + + const firstArg = args[0]!; + if (!Node.isObjectLiteralExpression(firstArg)) continue; + + const allowedHostsProp = firstArg.getProperty('allowedHosts'); + if (!allowedHostsProp || !Node.isPropertyAssignment(allowedHostsProp)) continue; + + const initializer = allowedHostsProp.getInitializer(); + if (!initializer) continue; + + const arrayText = initializer.getText(); + firstArg.replaceWithText(arrayText); + changesCount++; + + diagnostics.push( + info( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'hostHeaderValidation({ allowedHosts: [...] }) simplified to hostHeaderValidation([...]). Verify the migration.' + ) + ); + } + + return changesCount; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts new file mode 100644 index 0000000..fe6d271 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/handlerRegistration.ts @@ -0,0 +1,64 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils.js'; +import { NOTIFICATION_SCHEMA_TO_METHOD, SCHEMA_TO_METHOD } from '../mappings/schemaToMethodMap.js'; + +const ALL_SCHEMA_TO_METHOD: Record = { + ...SCHEMA_TO_METHOD, + ...NOTIFICATION_SCHEMA_TO_METHOD +}; + +export const handlerRegistrationTransform: Transform = { + name: 'Handler registration migration', + id: 'handlers', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + let changesCount = 0; + const diagnostics: Diagnostic[] = []; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + if (methodName !== 'setRequestHandler' && methodName !== 'setNotificationHandler') { + continue; + } + + const args = call.getArguments(); + if (args.length < 2) continue; + + const firstArg = args[0]!; + if (!Node.isIdentifier(firstArg)) continue; + + const schemaName = firstArg.getText(); + const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; + const methodString = ALL_SCHEMA_TO_METHOD[originalName]; + if (!methodString) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `Custom method handler: ${methodName}(${schemaName}, ...). ` + + `In v2, use the 3-arg form: ${methodName}('method/name', { params, result? }, handler). ` + + `See migration.md for details.` + ) + ); + continue; + } + + if (!isImportedFromMcp(sourceFile, schemaName)) continue; + + firstArg.replaceWithText(`'${methodString}'`); + changesCount++; + + removeUnusedImport(sourceFile, schemaName, true); + } + + return { changesCount, diagnostics }; + } +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts new file mode 100644 index 0000000..96e9308 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -0,0 +1,288 @@ +import type { SourceFile } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; +import { v2Gap, warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, getSdkExports, getSdkImports, isTypeOnlyImport } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; +import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; + +const REEXPORT_WARNINGS: Record = { + ErrorCode: 'Re-exported ErrorCode was split into ProtocolErrorCode and SdkErrorCode in v2. Update this re-export manually.', + RequestHandlerExtra: + 'Re-exported RequestHandlerExtra was renamed to ServerContext/ClientContext in v2. Update this re-export manually.', + IsomorphicHeaders: 'Re-exported IsomorphicHeaders was removed in v2 (replaced by standard Headers API). Remove this re-export.', + StreamableHTTPError: + 'Re-exported StreamableHTTPError was renamed to SdkError in v2 with different constructor. Update this re-export manually.' +}; + +export const importPathsTransform: Transform = { + name: 'Import path rewrites', + id: 'imports', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + const usedPackages = new Set(); + let changesCount = 0; + + const sdkImports = getSdkImports(sourceFile); + const sdkExports = getSdkExports(sourceFile); + if (sdkImports.length === 0 && sdkExports.length === 0) { + return { changesCount: 0, diagnostics: [] }; + } + + const filePath = sourceFile.getFilePath(); + + changesCount += rewriteExportDeclarations(sdkExports, sourceFile, filePath, context, diagnostics, usedPackages); + + if (sdkImports.length === 0) { + return { changesCount, diagnostics, usedPackages }; + } + + const hasClientImport = sdkImports.some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/client/'); + }); + const hasServerImport = sdkImports.some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/server/'); + }); + + const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + + interface PendingImport { + names: string[]; + isTypeOnly: boolean; + } + const pendingImports = new Map(); + + function addPending(target: string, names: string[], isTypeOnly: boolean): void { + if (!pendingImports.has(target)) { + pendingImports.set(target, []); + } + pendingImports.get(target)!.push({ names, isTypeOnly }); + } + + for (const imp of sdkImports) { + const specifier = imp.getModuleSpecifierValue(); + const namedImports = imp.getNamedImports(); + const typeOnly = isTypeOnlyImport(imp); + const line = imp.getStartLineNumber(); + const defaultImport = imp.getDefaultImport(); + const namespaceImport = imp.getNamespaceImport(); + + let mapping = IMPORT_MAP[specifier]; + + if (!mapping && isAuthImport(specifier)) { + mapping = { + target: '', + status: 'removed', + removalMessage: + 'Server auth removed in v2. For RS auth, see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).' + }; + } + + if (!mapping) { + diagnostics.push(warning(filePath, line, `Unknown SDK import path: ${specifier}. Manual migration required.`)); + continue; + } + + if (mapping.status === 'removed') { + imp.remove(); + changesCount++; + const diagFn = mapping.isV2Gap ? v2Gap : warning; + diagnostics.push(diagFn(filePath, line, mapping.removalMessage ?? `Import removed: ${specifier}`)); + continue; + } + + let targetPackage = mapping.target; + if (targetPackage === 'RESOLVE_BY_CONTEXT') { + targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport, { + filePath, + line, + diagnostics + }); + } + + const symbolsToRenameInFile: Array<[string, string]> = []; + if (mapping.renamedSymbols) { + for (const [oldName, newName] of Object.entries(mapping.renamedSymbols)) { + const matchingImport = namedImports.find(n => n.getName() === oldName); + if (matchingImport && !matchingImport.getAliasNode()) { + symbolsToRenameInFile.push([oldName, newName]); + } + } + } + + const hasAlias = namedImports.some(n => n.getAliasNode() !== undefined); + if (defaultImport || namespaceImport || hasAlias) { + let effectiveTarget = targetPackage; + if (mapping.symbolTargetOverrides && !namespaceImport && !defaultImport) { + const allOverridden = namedImports.length > 0 && namedImports.every(n => n.getName() in mapping.symbolTargetOverrides!); + if (allOverridden) { + effectiveTarget = mapping.symbolTargetOverrides[namedImports[0]!.getName()]!; + } else if (namedImports.some(n => n.getName() in mapping.symbolTargetOverrides!)) { + diagnostics.push( + warning( + filePath, + line, + `Aliased import from ${specifier} mixes symbols that belong to different v2 packages. ` + + `Split the import manually so each symbol targets the correct package.` + ) + ); + } + } + usedPackages.add(effectiveTarget); + imp.setModuleSpecifier(effectiveTarget); + if (mapping.renamedSymbols) { + for (const n of namedImports) { + const newName = mapping.renamedSymbols[n.getName()]; + if (newName) { + n.setName(newName); + } + } + if (namespaceImport) { + diagnostics.push( + warning( + filePath, + line, + `Namespace import of ${specifier}: exported symbol(s) ${Object.keys(mapping.renamedSymbols).join(', ')} ` + + `were renamed in ${effectiveTarget}. Update qualified accesses manually.` + ) + ); + } + } + changesCount++; + for (const [oldName, newName] of symbolsToRenameInFile) { + renameAllReferences(sourceFile, oldName, newName); + } + continue; + } + + for (const n of namedImports) { + const name = n.getName(); + const resolvedName = mapping.renamedSymbols?.[name] ?? name; + const specifierTypeOnly = typeOnly || n.isTypeOnly(); + const symbolTarget = mapping.symbolTargetOverrides?.[name] ?? targetPackage; + usedPackages.add(symbolTarget); + addPending(symbolTarget, [resolvedName], specifierTypeOnly); + } + imp.remove(); + changesCount++; + for (const [oldName, newName] of symbolsToRenameInFile) { + renameAllReferences(sourceFile, oldName, newName); + } + } + + for (const [target, groups] of pendingImports) { + const typeOnlyNames = new Set(); + const valueNames = new Set(); + for (const group of groups) { + for (const name of group.names) { + if (group.isTypeOnly) { + typeOnlyNames.add(name); + } else { + valueNames.add(name); + } + } + } + + if (valueNames.size > 0) { + addOrMergeImport(sourceFile, target, [...valueNames], false, insertIndex); + } + if (typeOnlyNames.size > 0) { + const typeInsertIndex = valueNames.size > 0 ? insertIndex + 1 : insertIndex; + addOrMergeImport(sourceFile, target, [...typeOnlyNames], true, typeInsertIndex); + } + } + + return { changesCount, diagnostics, usedPackages }; + } +}; + +function rewriteExportDeclarations( + sdkExports: import('ts-morph').ExportDeclaration[], + sourceFile: import('ts-morph').SourceFile, + filePath: string, + context: TransformContext, + diagnostics: Diagnostic[], + usedPackages: Set +): number { + let changesCount = 0; + + for (const exp of sdkExports) { + const specifier = exp.getModuleSpecifierValue(); + if (!specifier) continue; + + const line = exp.getStartLineNumber(); + let mapping = IMPORT_MAP[specifier]; + + if (!mapping && isAuthImport(specifier)) { + mapping = { + target: '', + status: 'removed', + removalMessage: + 'Server auth removed in v2. For RS auth, see @modelcontextprotocol/express. For full OAuth AS, see @modelcontextprotocol/server-auth-legacy (PR #1908).' + }; + } + + if (!mapping) { + diagnostics.push(warning(filePath, line, `Unknown SDK export path: ${specifier}. Manual migration required.`)); + continue; + } + + if (mapping.status === 'removed') { + exp.remove(); + changesCount++; + const diagFn = mapping.isV2Gap ? v2Gap : warning; + diagnostics.push(diagFn(filePath, line, mapping.removalMessage ?? `Export removed: ${specifier}`)); + continue; + } + + let targetPackage = mapping.target; + if (targetPackage === 'RESOLVE_BY_CONTEXT') { + const hasClientImport = sourceFile.getImportDeclarations().some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; + }); + const hasServerImport = sourceFile.getImportDeclarations().some(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; + }); + targetPackage = resolveTypesPackage(context, hasClientImport, hasServerImport); + } + + if (mapping.symbolTargetOverrides) { + const namedExports = exp.getNamedExports(); + const allOverridden = namedExports.length > 0 && namedExports.every(s => s.getName() in mapping.symbolTargetOverrides!); + if (allOverridden) { + targetPackage = mapping.symbolTargetOverrides[namedExports[0]!.getName()]!; + } else if (namedExports.some(s => s.getName() in mapping.symbolTargetOverrides!)) { + diagnostics.push( + warning( + filePath, + line, + `Re-export from ${specifier} mixes symbols that belong to different v2 packages. ` + + `Split the export manually so each symbol targets the correct package.` + ) + ); + } + } + usedPackages.add(targetPackage); + exp.setModuleSpecifier(targetPackage); + for (const spec of exp.getNamedExports()) { + const name = spec.getName(); + const newName = mapping.renamedSymbols?.[name] ?? SIMPLE_RENAMES[name]; + if (newName) { + if (!spec.getAliasNode()) spec.setAlias(name); + spec.setName(newName); + } + if (REEXPORT_WARNINGS[name]) { + diagnostics.push(warning(filePath, line, REEXPORT_WARNINGS[name]!)); + } + } + changesCount++; + } + + return changesCount; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts new file mode 100644 index 0000000..7b6b54b --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/index.ts @@ -0,0 +1,51 @@ +import type { Transform } from '../../../types.js'; +import { contextTypesTransform } from './contextTypes.js'; +import { expressMiddlewareTransform } from './expressMiddleware.js'; +import { handlerRegistrationTransform } from './handlerRegistration.js'; +import { importPathsTransform } from './importPaths.js'; +import { mcpServerApiTransform } from './mcpServerApi.js'; +import { mockPathsTransform } from './mockPaths.js'; +import { removedApisTransform } from './removedApis.js'; +import { schemaParamRemovalTransform } from './schemaParamRemoval.js'; +import { specSchemaAccessTransform } from './specSchemaAccess.js'; +import { symbolRenamesTransform } from './symbolRenames.js'; + +// Ordering matters — do not reorder without understanding dependencies: +// +// 1. importPaths MUST run first: rewrites import specifiers from v1 paths +// (e.g., @modelcontextprotocol/sdk/types.js) to v2 packages. Later +// transforms depend on the rewritten import declarations. +// +// 2. symbolRenames runs early: renames imported symbols (e.g., McpError → +// ProtocolError) and rewrites type references (e.g., SchemaInput → +// StandardSchemaWithJSON.InferInput). +// +// 3. removedApis runs after symbolRenames: handles removed Zod helpers, +// IsomorphicHeaders, and StreamableHTTPError. Conceptually different +// from renames — these are removals with diagnostic guidance. +// +// 4. mcpServerApi SHOULD run before contextTypes: it rewrites .tool() etc. +// to .registerTool() etc. contextTypes handles both old and new names, +// but running mcpServerApi first ensures consistent argument structure. +// +// 5. handlerRegistration, schemaParamRemoval, and expressMiddleware are +// independent of each other but all depend on importPaths having run. +// +// 6. specSchemaAccess runs after handlerRegistration and schemaParamRemoval: +// those transforms remove spec schema references they handle. specSchemaAccess +// then processes remaining standalone usages (safeParse, parse, z.infer, etc.). +// +// 7. mockPaths runs last: handles test mocks and dynamic imports, +// independent of the other transforms. +export const v1ToV2Transforms: Transform[] = [ + importPathsTransform, + symbolRenamesTransform, + removedApisTransform, + mcpServerApiTransform, + handlerRegistrationTransform, + schemaParamRemovalTransform, + specSchemaAccessTransform, + expressMiddlewareTransform, + contextTypesTransform, + mockPathsTransform +]; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts new file mode 100644 index 0000000..cc9a3a5 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mcpServerApi.ts @@ -0,0 +1,523 @@ +import type { CallExpression, SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { info, warning } from '../../../utils/diagnostics.js'; +import { isOriginalNameImportedFromMcp, resolveLocalImportName } from '../../../utils/importUtils.js'; + +export const mcpServerApiTransform: Transform = { + name: 'McpServer API migration', + id: 'mcpserver-api', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + if (!isOriginalNameImportedFromMcp(sourceFile, 'McpServer')) { + return { changesCount: 0, diagnostics: [] }; + } + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + const toolCalls: CallExpression[] = []; + const promptCalls: CallExpression[] = []; + const resourceCalls: CallExpression[] = []; + const registerToolCalls: CallExpression[] = []; + const registerPromptCalls: CallExpression[] = []; + const registerResourceCalls: CallExpression[] = []; + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + const methodName = expr.getName(); + + switch (methodName) { + case 'tool': { + toolCalls.push(call); + break; + } + case 'prompt': { + promptCalls.push(call); + break; + } + case 'resource': { + resourceCalls.push(call); + break; + } + case 'registerTool': { + registerToolCalls.push(call); + break; + } + case 'registerPrompt': { + registerPromptCalls.push(call); + break; + } + case 'registerResource': { + registerResourceCalls.push(call); + break; + } + } + } + + for (const call of toolCalls) { + const result = migrateToolCall(call, sourceFile, diagnostics); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .tool() call. Manual migration required.' + ) + ); + } + } + + for (const call of promptCalls) { + const result = migratePromptCall(call, sourceFile, diagnostics); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .prompt() call. Manual migration required.' + ) + ); + } + } + + for (const call of resourceCalls) { + const result = migrateResourceCall(call, sourceFile); + if (result) { + changesCount++; + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Could not automatically migrate .resource() call. Manual migration required.' + ) + ); + } + } + + for (const call of registerToolCalls) { + if (wrapSchemaInConfig(call, 'inputSchema', sourceFile, diagnostics)) { + changesCount++; + } + } + + for (const call of registerPromptCalls) { + if (wrapSchemaInConfig(call, 'argsSchema', sourceFile, diagnostics)) { + changesCount++; + } + } + + for (const call of registerResourceCalls) { + if (wrapSchemaInConfig(call, 'uriSchema', sourceFile, diagnostics)) { + changesCount++; + } + } + + changesCount += migrateConstructorTaskOptions(sourceFile, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function isStringArg(node: Node): boolean { + return Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node) || Node.isTemplateExpression(node); +} + +function wrapWithZObject(schemaText: string): string { + return `z.object(${schemaText})`; +} + +function maybeWrapSchema(node: Node): string { + const text = node.getText(); + if (Node.isObjectLiteralExpression(node)) { + return wrapWithZObject(text); + } + return text; +} + +function emitWrapDiagnostic(node: Node, sourceFile: SourceFile, call: CallExpression, diagnostics: Diagnostic[]): void { + if (Node.isObjectLiteralExpression(node)) { + diagnostics.push( + info( + sourceFile.getFilePath(), + call.getStartLineNumber(), + 'Raw object literal wrapped with z.object(). Verify that zod (z) is imported in this file.' + ) + ); + } +} + +/** + * For existing registerTool/registerPrompt/registerResource calls, + * wrap the specified schema property with z.object() if it's a raw object literal. + */ +function wrapSchemaInConfig(call: CallExpression, schemaPropertyName: string, sourceFile: SourceFile, diagnostics: Diagnostic[]): boolean { + const args = call.getArguments(); + // registerTool/registerPrompt: (name, config, callback) + // registerResource: (name, uri, config, callback) + // Find the config argument by looking for an object literal + let configArg: Node | undefined; + for (const arg of args) { + if (Node.isObjectLiteralExpression(arg)) { + configArg = arg; + break; + } + } + + if (!configArg || !Node.isObjectLiteralExpression(configArg)) return false; + + const schemaProp = configArg.getProperty(schemaPropertyName); + if (!schemaProp) return false; + + if (Node.isShorthandPropertyAssignment(schemaProp)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `Shorthand \`{ ${schemaPropertyName} }\` in config: verify the value is a z.object() schema, not a raw object. V2 requires a Zod schema.` + ) + ); + return false; + } + + if (!Node.isPropertyAssignment(schemaProp)) return false; + + const initializer = schemaProp.getInitializer(); + if (!initializer) return false; + + if (Node.isObjectLiteralExpression(initializer)) { + const wrapped = wrapWithZObject(initializer.getText()); + initializer.replaceWithText(wrapped); + diagnostics.push( + info( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `Raw object literal in ${schemaPropertyName} wrapped with z.object(). Verify that zod (z) is imported in this file.` + ) + ); + return true; + } + + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `\`${schemaPropertyName}\` value is not an object literal — verify it is a z.object() schema. V2 requires a Zod schema, not a raw object.` + ) + ); + return false; +} + +function migrateToolCall(call: CallExpression, sourceFile: SourceFile, diagnostics: Diagnostic[]): boolean { + const args = call.getArguments(); + if (args.length < 2) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + let description: string | undefined; + let schema: string | undefined; + let annotations: string | undefined; + let callbackText: string | undefined; + + switch (args.length) { + case 2: { + // server.tool(name, callback) + callbackText = args[1]!.getText(); + + break; + } + case 3: { + const arg1 = args[1]!; + if (isStringArg(arg1)) { + // server.tool(name, description, callback) + description = arg1.getText(); + callbackText = args[2]!.getText(); + } else { + // server.tool(name, schema, callback) + emitWrapDiagnostic(arg1, sourceFile, call, diagnostics); + schema = maybeWrapSchema(arg1); + callbackText = args[2]!.getText(); + } + + break; + } + case 4: { + const arg1 = args[1]!; + if (isStringArg(arg1)) { + // server.tool(name, description, schema, callback) + description = arg1.getText(); + emitWrapDiagnostic(args[2]!, sourceFile, call, diagnostics); + schema = maybeWrapSchema(args[2]!); + } else { + // server.tool(name, schema, annotations, callback) + emitWrapDiagnostic(arg1, sourceFile, call, diagnostics); + schema = maybeWrapSchema(arg1); + annotations = args[2]!.getText(); + } + callbackText = args[3]!.getText(); + + break; + } + case 5: { + // server.tool(name, description, schema, annotations, callback) + description = args[1]!.getText(); + emitWrapDiagnostic(args[2]!, sourceFile, call, diagnostics); + schema = maybeWrapSchema(args[2]!); + annotations = args[3]!.getText(); + callbackText = args[4]!.getText(); + + break; + } + default: { + return false; + } + } + + const configParts: string[] = []; + if (description) configParts.push(`description: ${description}`); + if (schema) configParts.push(`inputSchema: ${schema}`); + if (annotations) configParts.push(`annotations: ${annotations}`); + const configObj = configParts.length > 0 ? `{ ${configParts.join(', ')} }` : '{}'; + + expr.getNameNode().replaceWithText('registerTool'); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, configObj, callbackText!]); + + return true; +} + +function migratePromptCall(call: CallExpression, sourceFile: SourceFile, diagnostics: Diagnostic[]): boolean { + const args = call.getArguments(); + if (args.length < 2) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + let description: string | undefined; + let schema: string | undefined; + let callbackText: string | undefined; + + switch (args.length) { + case 2: { + callbackText = args[1]!.getText(); + + break; + } + case 3: { + const arg1 = args[1]!; + if (isStringArg(arg1)) { + description = arg1.getText(); + callbackText = args[2]!.getText(); + } else { + emitWrapDiagnostic(arg1, sourceFile, call, diagnostics); + schema = maybeWrapSchema(arg1); + callbackText = args[2]!.getText(); + } + + break; + } + case 4: { + description = args[1]!.getText(); + emitWrapDiagnostic(args[2]!, sourceFile, call, diagnostics); + schema = maybeWrapSchema(args[2]!); + callbackText = args[3]!.getText(); + + break; + } + default: { + return false; + } + } + + const configParts: string[] = []; + if (description) configParts.push(`description: ${description}`); + if (schema) configParts.push(`argsSchema: ${schema}`); + const configObj = configParts.length > 0 ? `{ ${configParts.join(', ')} }` : '{}'; + + expr.getNameNode().replaceWithText('registerPrompt'); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, configObj, callbackText!]); + + return true; +} + +function migrateResourceCall(call: CallExpression, _sourceFile: SourceFile): boolean { + const args = call.getArguments(); + if (args.length < 3) return false; + + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) return false; + + const nameArg = args[0]!; + if (!isStringArg(nameArg)) return false; + const nameText = nameArg.getText(); + + const uriArg = args[1]!; + const uriText = uriArg.getText(); + + if (args.length === 3) { + // server.resource(name, uri, callback) → server.registerResource(name, uri, {}, callback) + expr.getNameNode().replaceWithText('registerResource'); + const callbackText = args[2]!.getText(); + for (let i = args.length - 1; i >= 0; i--) { + call.removeArgument(i); + } + call.addArguments([nameText, uriText, '{}', callbackText]); + } else if (args.length === 4) { + // server.resource(name, uri, metadata, callback) → server.registerResource(name, uri, metadata, callback) + // Already has metadata, just rename the method + expr.getNameNode().replaceWithText('registerResource'); + } else { + return false; + } + + return true; +} + +const TASK_OPTIONS = ['taskStore', 'taskMessageQueue'] as const; + +function migrateConstructorTaskOptions(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + const localName = resolveLocalImportName(sourceFile, 'McpServer'); + if (!localName) return 0; + + let changes = 0; + + for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { + if (node.wasForgotten()) continue; + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== localName) continue; + + const args = node.getArguments(); + if (args.length < 2) continue; + + const optionsArg = args[1]!; + if (!Node.isObjectLiteralExpression(optionsArg)) continue; + + // Check if any task options are present at the top level + const propsToMove: string[] = []; + for (const propName of TASK_OPTIONS) { + if (optionsArg.getProperty(propName)) { + propsToMove.push(propName); + } + } + if (propsToMove.length === 0) continue; + + // Find the tasks object's position within the options text using AST, + // then do all mutations via a single text replacement to avoid node invalidation. + const capabilitiesProp = optionsArg.getProperty('capabilities'); + let tasksObjStart = -1; + let tasksObjEnd = -1; + const optionsStart = optionsArg.getStart(); + if (capabilitiesProp && Node.isPropertyAssignment(capabilitiesProp)) { + const capInit = capabilitiesProp.getInitializer(); + if (capInit && Node.isObjectLiteralExpression(capInit)) { + const tasksProp = capInit.getProperty('tasks'); + if (tasksProp && Node.isPropertyAssignment(tasksProp)) { + const tasksInit = tasksProp.getInitializer(); + if (tasksInit && Node.isObjectLiteralExpression(tasksInit)) { + tasksObjStart = tasksInit.getStart() - optionsStart; + tasksObjEnd = tasksInit.getEnd() - optionsStart; + } + } + } + } + + if (tasksObjStart === -1) { + for (const propName of propsToMove) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Move '${propName}' from McpServer options into capabilities.tasks — v2 expects task runtime options inside the tasks capability.` + ) + ); + } + continue; + } + + // Single text replacement: remove top-level props and insert into tasks object. + // Use AST nodes (already located via getProperty) to get brace-balanced text and + // exact positions, avoiding regex truncation on values containing commas/braces. + // Collect all properties first, then process in reverse position order so each + // removal doesn't invalidate the positions of subsequent removals. + let optionsText = optionsArg.getText(); + const argStart = optionsArg.getStart(); + const propsWithPositions: { text: string; start: number; end: number }[] = []; + for (const propName of propsToMove) { + const prop = optionsArg.getProperty(propName); + if (!prop) continue; + propsWithPositions.push({ + text: prop.getText(), + start: prop.getStart() - argStart, + end: prop.getEnd() - argStart + }); + } + const propTexts = propsWithPositions.map(p => p.text); + + // Remove in reverse position order so earlier positions remain valid + const sortedProps = propsWithPositions.toSorted((a, b) => b.start - a.start); + for (const { start, end } of sortedProps) { + let remStart = start; + let remEnd = end; + // Consume trailing comma and whitespace + const afterProp = optionsText.slice(remEnd); + const trailingMatch = afterProp.match(/^\s*,?\s*/); + if (trailingMatch) { + remEnd += trailingMatch[0].length; + } + // Consume leading whitespace/newline + const beforeProp = optionsText.slice(0, remStart); + const leadingMatch = beforeProp.match(/[\n\r]?\s*$/); + if (leadingMatch) { + remStart -= leadingMatch[0].length; + } + optionsText = optionsText.slice(0, remStart) + optionsText.slice(remEnd); + // Adjust tasks position if removal was before it + if (remStart < tasksObjStart) { + const shift = remEnd - remStart; + tasksObjStart -= shift; + tasksObjEnd -= shift; + } + } + + if (propTexts.length === 0) continue; + + // Insert into the tasks object (just before its closing brace) + const tasksText = optionsText.slice(tasksObjStart, tasksObjEnd); + const closingBrace = tasksText.lastIndexOf('}'); + const before = tasksText.slice(0, closingBrace).trimEnd(); + const sep = before.length > 1 ? ',\n' : '\n'; + const newTasksText = before + sep + propTexts.join(',\n') + '\n' + tasksText.slice(closingBrace); + optionsText = optionsText.slice(0, tasksObjStart) + newTasksText + optionsText.slice(tasksObjEnd); + + // Clean up double/trailing commas + optionsText = optionsText.replaceAll(/,(\s*,)/g, ','); + optionsText = optionsText.replaceAll(/,(\s*})/g, '$1'); + + optionsArg.replaceWithText(optionsText); + changes += propTexts.length; + } + + return changes; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts new file mode 100644 index 0000000..e857b38 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/mockPaths.ts @@ -0,0 +1,325 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { v2Gap, warning } from '../../../utils/diagnostics.js'; +import { isSdkSpecifier } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import { IMPORT_MAP, isAuthImport } from '../mappings/importMap.js'; +import { SIMPLE_RENAMES } from '../mappings/symbolMap.js'; + +const MOCK_METHODS = new Set(['mock', 'doMock']); +const MOCK_CALLERS = new Set(['vi', 'jest']); + +export const mockPathsTransform: Transform = { + name: 'Mock and dynamic import path rewrites', + id: 'mock-paths', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + const usedPackages = new Set(); + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + + if (Node.isPropertyAccessExpression(expr)) { + const objName = expr.getExpression().getText(); + const methodName = expr.getName(); + if (MOCK_CALLERS.has(objName) && MOCK_METHODS.has(methodName)) { + changesCount += rewriteMockCall(call, sourceFile, context, diagnostics, usedPackages); + } + } + } + + changesCount += rewriteDynamicImports(sourceFile, context, diagnostics, usedPackages); + + return { changesCount, diagnostics, usedPackages }; + } +}; + +function resolveTarget( + specifier: string, + context: TransformContext, + sourceFile: SourceFile, + diagnosticSink?: { filePath: string; line: number; diagnostics: Diagnostic[] } +): + | { target: string; renamedSymbols?: Record; symbolTargetOverrides?: Record } + | { removed: true; isV2Gap?: boolean; removalMessage?: string } + | null { + const mapping = IMPORT_MAP[specifier]; + if (!mapping && isAuthImport(specifier)) return { removed: true }; + if (!mapping) return null; + if (mapping.status === 'removed') return { removed: true, isV2Gap: mapping.isV2Gap, removalMessage: mapping.removalMessage }; + + let target = mapping.target; + if (target === 'RESOLVE_BY_CONTEXT') { + const hasClient = sourceFile.getImportDeclarations().some(i => { + const s = i.getModuleSpecifierValue(); + return s.includes('/client/') || s === '@modelcontextprotocol/client'; + }); + const hasServer = sourceFile.getImportDeclarations().some(i => { + const s = i.getModuleSpecifierValue(); + return s.includes('/server/') || s === '@modelcontextprotocol/server'; + }); + target = resolveTypesPackage(context, hasClient, hasServer, diagnosticSink); + } + + return { target, renamedSymbols: mapping.renamedSymbols, symbolTargetOverrides: mapping.symbolTargetOverrides }; +} + +function rewriteMockCall( + call: import('ts-morph').CallExpression, + sourceFile: SourceFile, + context: TransformContext, + diagnostics: Diagnostic[], + usedPackages: Set +): number { + const args = call.getArguments(); + if (args.length === 0) return 0; + + const firstArg = args[0]!; + if (!Node.isStringLiteral(firstArg)) return 0; + + const specifier = firstArg.getLiteralValue(); + if (!isSdkSpecifier(specifier)) return 0; + + const resolved = resolveTarget(specifier, context, sourceFile, { + filePath: sourceFile.getFilePath(), + line: call.getStartLineNumber(), + diagnostics + }); + if (resolved === null) { + diagnostics.push( + warning(sourceFile.getFilePath(), call.getStartLineNumber(), `Unknown SDK mock path: ${specifier}. Manual migration required.`) + ); + return 0; + } + if ('removed' in resolved) { + const diagFn = resolved.isV2Gap ? v2Gap : warning; + diagnostics.push( + diagFn( + sourceFile.getFilePath(), + call.getStartLineNumber(), + resolved.removalMessage ?? `Mock references removed SDK path: ${specifier}. Manual migration required.` + ) + ); + return 0; + } + + let changes = 0; + + let effectiveTarget = resolved.target; + if (resolved.symbolTargetOverrides && args.length >= 2) { + const factorySymbols = collectFactorySymbols(args[1]!); + const allOverridden = factorySymbols.length > 0 && factorySymbols.every(s => s in resolved.symbolTargetOverrides!); + const someOverridden = factorySymbols.some(s => s in resolved.symbolTargetOverrides!); + if (allOverridden) { + effectiveTarget = resolved.symbolTargetOverrides[factorySymbols[0]!]!; + } else if (someOverridden) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + call.getStartLineNumber(), + `Mock factory from ${specifier} mixes symbols that belong to different v2 packages. ` + + `Split the mock manually so each symbol targets the correct package.` + ) + ); + } + } + + usedPackages.add(effectiveTarget); + firstArg.setLiteralValue(effectiveTarget); + changes++; + + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + if (args.length >= 2) { + changes += renameSymbolsInFactory(args[1]!, allRenames); + } + + return changes; +} + +function getTopLevelObjectLiteral(factoryArg: import('ts-morph').Node): import('ts-morph').ObjectLiteralExpression | undefined { + if (Node.isObjectLiteralExpression(factoryArg)) return factoryArg; + + if (Node.isArrowFunction(factoryArg) || Node.isFunctionExpression(factoryArg)) { + const body = factoryArg.getBody(); + if (Node.isObjectLiteralExpression(body)) return body; + if (Node.isParenthesizedExpression(body)) { + const inner = body.getExpression(); + if (Node.isObjectLiteralExpression(inner)) return inner; + } + if (Node.isBlock(body)) { + for (const stmt of body.getStatements()) { + if (Node.isReturnStatement(stmt)) { + const expr = stmt.getExpression(); + if (expr && Node.isObjectLiteralExpression(expr)) return expr; + } + } + } + } + + return undefined; +} + +function collectFactorySymbols(factoryArg: import('ts-morph').Node): string[] { + const obj = getTopLevelObjectLiteral(factoryArg); + if (!obj) return []; + + const symbols: string[] = []; + for (const prop of obj.getProperties()) { + if (Node.isPropertyAssignment(prop) || Node.isShorthandPropertyAssignment(prop)) { + symbols.push(prop.getName()); + } + } + return symbols; +} + +function renameSymbolsInFactory(factoryArg: import('ts-morph').Node, renamedSymbols: Record): number { + const obj = getTopLevelObjectLiteral(factoryArg); + if (!obj) return 0; + + let changes = 0; + for (const prop of obj.getProperties()) { + if (Node.isPropertyAssignment(prop)) { + const name = prop.getName(); + const newName = renamedSymbols[name]; + if (newName) { + prop.getNameNode().replaceWithText(newName); + changes++; + } + } + + if (Node.isShorthandPropertyAssignment(prop)) { + const name = prop.getName(); + const newName = renamedSymbols[name]; + if (newName) { + prop.replaceWithText(`${newName}: ${name}`); + changes++; + } + } + } + + return changes; +} + +function rewriteDynamicImports( + sourceFile: SourceFile, + context: TransformContext, + diagnostics: Diagnostic[], + usedPackages: Set +): number { + let changes = 0; + + sourceFile.forEachDescendant(node => { + if (!Node.isCallExpression(node)) return; + + const expr = node.getExpression(); + if (expr.getKind() !== SyntaxKind.ImportKeyword) return; + + const args = node.getArguments(); + if (args.length === 0) return; + + const firstArg = args[0]!; + if (!Node.isStringLiteral(firstArg)) return; + + const specifier = firstArg.getLiteralValue(); + if (!isSdkSpecifier(specifier)) return; + + const resolved = resolveTarget(specifier, context, sourceFile, { + filePath: sourceFile.getFilePath(), + line: node.getStartLineNumber(), + diagnostics + }); + if (resolved === null) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Unknown SDK dynamic import path: ${specifier}. Manual migration required.` + ) + ); + return; + } + if ('removed' in resolved) { + const diagFn = resolved.isV2Gap ? v2Gap : warning; + diagnostics.push( + diagFn( + sourceFile.getFilePath(), + node.getStartLineNumber(), + resolved.removalMessage ?? `Dynamic import references removed SDK path: ${specifier}. Manual migration required.` + ) + ); + return; + } + + let effectiveTarget = resolved.target; + const allRenames: Record = { ...SIMPLE_RENAMES, ...resolved.renamedSymbols }; + + // Check if destructured symbols should route to an override target + if (resolved.symbolTargetOverrides) { + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + const elements = nameNode.getElements(); + const allOverridden = + elements.length > 0 && + elements.every(el => { + const key = el.getPropertyNameNode()?.getText() ?? el.getName(); + return key in resolved.symbolTargetOverrides!; + }); + if (allOverridden) { + effectiveTarget = + resolved.symbolTargetOverrides[elements[0]!.getPropertyNameNode()?.getText() ?? elements[0]!.getName()]!; + } + } + } + } + } + + usedPackages.add(effectiveTarget); + firstArg.setLiteralValue(effectiveTarget); + changes++; + + const parent = node.getParent(); + if (parent && Node.isAwaitExpression(parent)) { + const grandParent = parent.getParent(); + if (grandParent && Node.isVariableDeclaration(grandParent)) { + const nameNode = grandParent.getNameNode(); + if (Node.isObjectBindingPattern(nameNode)) { + for (const element of nameNode.getElements()) { + const propertyName = element.getPropertyNameNode()?.getText(); + const bindingName = element.getName(); + const lookupKey = propertyName ?? bindingName; + const newName = allRenames[lookupKey]; + if (newName) { + if (propertyName) { + element.getPropertyNameNode()!.replaceWithText(newName); + } else { + element.replaceWithText(`${newName}: ${bindingName}`); + } + changes++; + } + } + } + const moduleRenames = resolved.renamedSymbols ?? {}; + if (!Node.isObjectBindingPattern(nameNode) && Object.keys(moduleRenames).length > 0) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + `Dynamic import assigned to variable (not destructured). Symbol renames (${Object.keys(moduleRenames).join(', ')}) were not applied. Manual update may be needed.` + ) + ); + } + } + } + }); + + return changes; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts new file mode 100644 index 0000000..82f5c2b --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/removedApis.ts @@ -0,0 +1,192 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, isAnyMcpSpecifier } from '../../../utils/importUtils.js'; + +const REMOVED_ZOD_HELPERS: Record = { + schemaToJson: + "Removed in v2. Use `fromJsonSchema()` from @modelcontextprotocol/server for JSON Schema, or your schema library's native conversion.", + parseSchemaAsync: "Removed in v2. Use your schema library's validation directly (e.g., Zod's `.safeParseAsync()`).", + getSchemaShape: "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + getSchemaDescription: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + isOptionalSchema: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API.", + unwrapOptionalSchema: + "Removed in v2. These Zod-specific introspection helpers have no v2 equivalent. Use your schema library's native API." +}; + +export const removedApisTransform: Transform = { + name: 'Removed API handling', + id: 'removed-apis', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + changesCount += handleRemovedZodHelpers(sourceFile, diagnostics); + changesCount += handleIsomorphicHeaders(sourceFile, diagnostics); + changesCount += handleStreamableHTTPError(sourceFile, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function handleRemovedZodHelpers(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + interface Removal { + importName: string; + message: string; + line: number; + } + + const removals: Removal[] = []; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + const line = imp.getStartLineNumber(); + for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const message = REMOVED_ZOD_HELPERS[name]; + if (message) { + removals.push({ importName: name, message, line }); + } + } + } + + for (const removal of removals) { + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === removal.importName) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + break; + } + } + } + diagnostics.push(warning(sourceFile.getFilePath(), removal.line, `${removal.importName}: ${removal.message}`)); + } + + return removals.length; +} + +function handleIsomorphicHeaders(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + let foundImport: ReturnType[0]['getNamedImports']>[0] | undefined; + let foundImportDecl: ReturnType[0] | undefined; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'IsomorphicHeaders') { + foundImport = namedImport; + foundImportDecl = imp; + break; + } + } + if (foundImport) break; + } + + if (!foundImport || !foundImportDecl) return 0; + + const localName = foundImport.getAliasNode()?.getText() ?? 'IsomorphicHeaders'; + const line = foundImportDecl.getStartLineNumber(); + + renameAllReferences(sourceFile, localName, 'Headers'); + changesCount++; + + foundImport.remove(); + if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) { + foundImportDecl.remove(); + } + changesCount++; + + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + 'IsomorphicHeaders replaced with standard Web Headers API. Note: Headers uses .get()/.set() methods, not bracket access.' + ) + ); + + return changesCount; +} + +function handleStreamableHTTPError(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + let foundImport: ReturnType[0]['getNamedImports']>[0] | undefined; + let foundImportDecl: ReturnType[0] | undefined; + + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'StreamableHTTPError') { + foundImport = namedImport; + foundImportDecl = imp; + break; + } + } + if (foundImport) break; + } + + if (!foundImport || !foundImportDecl) return 0; + + const localName = foundImport.getAliasNode()?.getText() ?? 'StreamableHTTPError'; + const line = foundImportDecl.getStartLineNumber(); + const moduleSpec = foundImportDecl.getModuleSpecifierValue(); + + let hasConstructorCalls = false; + for (const node of sourceFile.getDescendantsOfKind(SyntaxKind.NewExpression)) { + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== localName) continue; + hasConstructorCalls = true; + diagnostics.push( + warning( + sourceFile.getFilePath(), + node.getStartLineNumber(), + 'new StreamableHTTPError(statusCode, statusText, body?) → new SdkError(code, message, data?). ' + + 'Constructor arguments differ — manual review required. Map HTTP status to SdkErrorCode enum value.' + ) + ); + } + + renameAllReferences(sourceFile, localName, 'SdkError'); + changesCount++; + + foundImport.remove(); + if (foundImportDecl.getNamedImports().length === 0 && !foundImportDecl.getDefaultImport() && !foundImportDecl.getNamespaceImport()) { + foundImportDecl.remove(); + } + + const targetModule = resolveTargetModule(sourceFile, moduleSpec); + const insertIndex = sourceFile.getImportDeclarations().length; + const importsToAdd = hasConstructorCalls ? ['SdkError', 'SdkErrorCode'] : ['SdkError']; + addOrMergeImport(sourceFile, targetModule, importsToAdd, false, insertIndex); + changesCount++; + + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + 'StreamableHTTPError replaced with SdkError. Constructor arguments differ — manual review required. ' + + 'HTTP status is now in error.data?.status.' + ) + ); + + return changesCount; +} + +function resolveTargetModule(sourceFile: SourceFile, originalModule: string): string { + const imp = sourceFile.getImportDeclarations().find(i => { + const spec = i.getModuleSpecifierValue(); + return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server'; + }); + if (imp) return imp.getModuleSpecifierValue(); + + if (originalModule.includes('/client')) return '@modelcontextprotocol/client'; + return '@modelcontextprotocol/server'; +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts new file mode 100644 index 0000000..eea8e33 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/schemaParamRemoval.ts @@ -0,0 +1,43 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import type { Transform, TransformContext, TransformResult } from '../../../types.js'; +import { isImportedFromMcp, removeUnusedImport, resolveOriginalImportName } from '../../../utils/importUtils.js'; + +const TARGET_METHODS = new Set(['request', 'callTool']); + +export const schemaParamRemovalTransform: Transform = { + name: 'Schema parameter removal', + id: 'schema-params', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + let changesCount = 0; + + const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + + for (const call of calls) { + const expr = call.getExpression(); + if (!Node.isPropertyAccessExpression(expr)) continue; + + const methodName = expr.getName(); + if (!TARGET_METHODS.has(methodName)) continue; + + const args = call.getArguments(); + if (args.length < 2) continue; + + const secondArg = args[1]!; + if (!Node.isIdentifier(secondArg)) continue; + + const schemaName = secondArg.getText(); + const originalName = resolveOriginalImportName(sourceFile, schemaName) ?? schemaName; + if (!originalName.endsWith('Schema')) continue; + if (!isImportedFromMcp(sourceFile, schemaName)) continue; + + call.removeArgument(1); + changesCount++; + + removeUnusedImport(sourceFile, schemaName, true); + } + + return { changesCount, diagnostics: [] }; + } +}; diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts new file mode 100644 index 0000000..a9e9e99 --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/specSchemaAccess.ts @@ -0,0 +1,350 @@ +import type { SourceFile } from 'ts-morph'; +import { Node, SyntaxKind } from 'ts-morph'; + +import { SPEC_SCHEMA_NAMES, specSchemaToTypeName } from '../../../generated/specSchemaMap.js'; +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils.js'; + +export const specSchemaAccessTransform: Transform = { + name: 'Spec schema standalone usage', + id: 'spec-schemas', + apply(sourceFile: SourceFile, _context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + const schemaImports = collectSpecSchemaImports(sourceFile); + if (schemaImports.size === 0) return { changesCount: 0, diagnostics: [] }; + + for (const [localName, originalName] of schemaImports) { + const typeName = specSchemaToTypeName(originalName); + if (!typeName) continue; + + const refs = findNonImportReferences(sourceFile, localName); + if (refs.length === 0) continue; + + for (const ref of refs) { + const result = handleReference(ref, localName, typeName, sourceFile, diagnostics); + if (result) changesCount++; + } + removeUnusedImport(sourceFile, localName, true); + } + + return { changesCount, diagnostics }; + } +}; + +function collectSpecSchemaImports(sourceFile: SourceFile): Map { + const result = new Map(); + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const n of imp.getNamedImports()) { + const exportName = n.getName(); + if (!SPEC_SCHEMA_NAMES.has(exportName)) continue; + const localName = n.getAliasNode()?.getText() ?? exportName; + result.set(localName, exportName); + } + } + return result; +} + +function findNonImportReferences(sourceFile: SourceFile, localName: string): import('ts-morph').Node[] { + const refs: import('ts-morph').Node[] = []; + sourceFile.forEachDescendant(node => { + if (!Node.isIdentifier(node)) return; + if (node.getText() !== localName) return; + const parent = node.getParent(); + if (parent && Node.isImportSpecifier(parent)) return; + refs.push(node); + }); + return refs; +} + +function handleReference( + ref: import('ts-morph').Node, + localName: string, + typeName: string, + sourceFile: SourceFile, + diagnostics: Diagnostic[] +): boolean { + // Pattern: z.infer — type position + if (isTypeofInTypePosition(ref)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + ref.getStartLineNumber(), + `Replace \`z.infer\` with the \`${typeName}\` type (already exported from the same v2 package).` + ) + ); + return false; + } + + // Pattern: XSchema.safeParse(v).success — auto-transform to isSpecType.X(v) + if (isSafeParseSuccessPattern(ref)) { + const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression; + const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression; + const successAccess = safeParseCall.getParent() as import('ts-morph').PropertyAccessExpression; + const args = safeParseCall.getArguments(); + const argText = args.length > 0 ? args[0]!.getText() : ''; + successAccess.replaceWithText(`isSpecType.${typeName}(${argText})`); + ensureImport(sourceFile, 'isSpecType'); + return true; + } + + // Pattern: const x = XSchema.safeParse(v) — auto-transform when result is captured in a variable + if (isSafeParsePattern(ref)) { + const safeParseAccess = ref.getParent() as import('ts-morph').PropertyAccessExpression; + const safeParseCall = safeParseAccess.getParent() as import('ts-morph').CallExpression; + + if (isCapturedSafeParsePattern(safeParseCall)) { + return rewriteCapturedSafeParse(safeParseCall, localName, typeName, sourceFile, diagnostics); + } + + diagnostics.push( + warning( + sourceFile.getFilePath(), + ref.getStartLineNumber(), + `${localName}.safeParse() not available in v2. Use \`isSpecType.${typeName}(value)\` for boolean validation, ` + + `or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` for full result.` + ) + ); + return false; + } + + // Pattern: XSchema.parse(v) — diagnostic only + if (isParsePattern(ref)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + ref.getStartLineNumber(), + `${localName}.parse() not available in v2. Use \`isSpecType.${typeName}(value)\` for validation, ` + + `or \`specTypeSchemas.${typeName}['~standard'].validate(value)\` and check for issues.` + ) + ); + return false; + } + + // Pattern: XSchema used as value (function arg, assignment, etc.) + const parent = ref.getParent(); + if (parent && Node.isPropertyAccessExpression(parent) && parent.getExpression() === ref) { + const line = ref.getStartLineNumber(); + ref.replaceWithText(`specTypeSchemas.${typeName}`); + ensureImport(sourceFile, 'specTypeSchemas'); + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse()/.parseAsync() are not available. Manual rewrite required.` + ) + ); + return true; + } + + if (parent && Node.isExportSpecifier(parent)) { + diagnostics.push( + warning( + sourceFile.getFilePath(), + ref.getStartLineNumber(), + `Re-export of ${localName} requires manual update: replace with specTypeSchemas.${typeName} or remove.` + ) + ); + return false; + } + + if (parent && Node.isShorthandPropertyAssignment(parent)) { + const line = ref.getStartLineNumber(); + parent.replaceWithText(`'${localName}': specTypeSchemas.${typeName}`); + ensureImport(sourceFile, 'specTypeSchemas'); + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + ) + ); + return true; + } + + if (parent && Node.isPropertyAssignment(parent) && parent.getNameNode() === ref) { + return false; + } + + if (parent && Node.isBindingElement(parent) && parent.getPropertyNameNode() === ref) { + return false; + } + + if (parent && Node.isPropertyAccessExpression(parent) && parent.getNameNode() === ref) { + return false; + } + + // Value position: replace identifier with specTypeSchemas.X + const line = ref.getStartLineNumber(); + ref.replaceWithText(`specTypeSchemas.${typeName}`); + ensureImport(sourceFile, 'specTypeSchemas'); + diagnostics.push( + warning( + sourceFile.getFilePath(), + line, + `Replaced ${localName} with specTypeSchemas.${typeName}. Note: typed as StandardSchemaV1, not ZodType — Zod methods like .safeParse()/.parse() are not available.` + ) + ); + return true; +} + +function isSafeParseSuccessPattern(ref: import('ts-morph').Node): boolean { + const parent = ref.getParent(); + if (!parent || !Node.isPropertyAccessExpression(parent)) return false; + if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false; + const grandParent = parent.getParent(); + if (!grandParent || !Node.isCallExpression(grandParent)) return false; + const greatGrandParent = grandParent.getParent(); + if (!greatGrandParent || !Node.isPropertyAccessExpression(greatGrandParent)) return false; + return greatGrandParent.getName() === 'success'; +} + +function isSafeParsePattern(ref: import('ts-morph').Node): boolean { + const parent = ref.getParent(); + if (!parent || !Node.isPropertyAccessExpression(parent)) return false; + if (parent.getName() !== 'safeParse' || parent.getExpression() !== ref) return false; + const grandParent = parent.getParent(); + return !!grandParent && Node.isCallExpression(grandParent); +} + +function isParsePattern(ref: import('ts-morph').Node): boolean { + const parent = ref.getParent(); + if (!parent || !Node.isPropertyAccessExpression(parent)) return false; + if (parent.getName() !== 'parse' || parent.getExpression() !== ref) return false; + const grandParent = parent.getParent(); + return !!grandParent && Node.isCallExpression(grandParent); +} + +function isTypeofInTypePosition(ref: import('ts-morph').Node): boolean { + const parent = ref.getParent(); + if (!parent) return false; + return Node.isTypeQuery(parent); +} + +/** + * Checks if a safeParse call result is captured in a `const` variable declaration. + * Pattern: `const x = Schema.safeParse(v);` + */ +function isCapturedSafeParsePattern(safeParseCall: import('ts-morph').CallExpression): boolean { + const parent = safeParseCall.getParent(); + if (!parent || !Node.isVariableDeclaration(parent)) return false; + const nameNode = parent.getNameNode(); + if (!Node.isIdentifier(nameNode)) return false; + const declList = parent.getParent(); + if (!declList || !Node.isVariableDeclarationList(declList)) return false; + const flags = declList.getDeclarationKind(); + return flags === 'const' || flags === 'let'; +} + +/** + * Rewrites a captured safeParse pattern: + * const x = Schema.safeParse(v) → const x = specTypeSchemas.T['~standard'].validate(v) + * x.success → x.issues === undefined + * x.data → x.value + * x.error → x.issues + */ +function rewriteCapturedSafeParse( + safeParseCall: import('ts-morph').CallExpression, + localName: string, + typeName: string, + sourceFile: SourceFile, + diagnostics: Diagnostic[] +): boolean { + const varDecl = safeParseCall.getParent() as import('ts-morph').VariableDeclaration; + const varName = varDecl.getName(); + + const args = safeParseCall.getArguments(); + const argText = args.length > 0 ? args[0]!.getText() : ''; + + // Rewrite the safeParse call + safeParseCall.replaceWithText(`specTypeSchemas.${typeName}['~standard'].validate(${argText})`); + ensureImport(sourceFile, 'specTypeSchemas'); + + // Find and rewrite all property accesses on the result variable (scoped to declaring block) + const replacements: { node: import('ts-morph').Node; newText: string }[] = []; + const scope = varDecl.getFirstAncestorByKind(SyntaxKind.Block) ?? sourceFile; + scope.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== varName) return; + + const propName = node.getName(); + switch (propName) { + case 'success': { + // Check for !x.success → x.issues !== undefined + const parentNode = node.getParent(); + if ( + parentNode && + Node.isPrefixUnaryExpression(parentNode) && + parentNode.getOperatorToken() === SyntaxKind.ExclamationToken + ) { + replacements.push({ node: parentNode, newText: `${varName}.issues !== undefined` }); + } else { + replacements.push({ node, newText: `(${varName}.issues === undefined)` }); + } + break; + } + case 'data': { + replacements.push({ node, newText: `${varName}.value` }); + break; + } + case 'error': { + const errorParent = node.getParent(); + if (errorParent && Node.isPropertyAccessExpression(errorParent) && errorParent.getExpression() === node) { + const subProp = errorParent.getName(); + if (subProp === 'issues') { + replacements.push({ node: errorParent, newText: `${varName}.issues` }); + } else if (subProp === 'message') { + replacements.push({ node: errorParent, newText: `${varName}.issues?.map(i => i.message).join(', ')` }); + } else { + diagnostics.push( + warning( + sourceFile.getFilePath(), + errorParent.getStartLineNumber(), + `${varName}.error.${subProp} has no StandardSchema equivalent. Manual migration required.` + ) + ); + } + } else { + replacements.push({ node, newText: `${varName}.issues` }); + } + break; + } + } + }); + + // Apply in reverse order to avoid position shifts + const sorted = replacements.toSorted((a, b) => b.node.getStart() - a.node.getStart()); + for (const { node, newText } of sorted) { + node.replaceWithText(newText); + } + + diagnostics.push( + warning( + sourceFile.getFilePath(), + varDecl.getStartLineNumber(), + `Rewrote ${localName}.safeParse() to specTypeSchemas.${typeName}['~standard'].validate(). ` + + `Result properties remapped: .success → .issues === undefined, .data → .value, .error → .issues.` + ) + ); + + return true; +} + +function ensureImport(sourceFile: SourceFile, symbol: string): void { + const existingImport = sourceFile.getImportDeclarations().find(imp => { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; + return imp.getNamedImports().some(n => n.getName() === symbol); + }); + if (existingImport) return; + + const targetPkg = sourceFile.getImportDeclarations().find(imp => { + const spec = imp.getModuleSpecifierValue(); + return spec === '@modelcontextprotocol/server' || spec === '@modelcontextprotocol/client'; + }); + const target = targetPkg?.getModuleSpecifierValue() ?? '@modelcontextprotocol/server'; + addOrMergeImport(sourceFile, target, [symbol], false, sourceFile.getImportDeclarations().length); +} diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts new file mode 100644 index 0000000..e01a11a --- /dev/null +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/symbolRenames.ts @@ -0,0 +1,352 @@ +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +import type { Diagnostic, Transform, TransformContext, TransformResult } from '../../../types.js'; +import { renameAllReferences } from '../../../utils/astUtils.js'; +import { info, warning } from '../../../utils/diagnostics.js'; +import { addOrMergeImport, isAnyMcpSpecifier, removeUnusedImport } from '../../../utils/importUtils.js'; +import { resolveTypesPackage } from '../../../utils/projectAnalyzer.js'; +import { ERROR_CODE_SDK_MEMBERS, SIMPLE_RENAMES } from '../mappings/symbolMap.js'; + +const SERVER_GENERIC_ARGS = new Set(['ServerRequest', 'ServerNotification']); +const CLIENT_GENERIC_ARGS = new Set(['ClientRequest', 'ClientNotification']); + +export const symbolRenamesTransform: Transform = { + name: 'Symbol renames', + id: 'symbols', + apply(sourceFile: SourceFile, context: TransformContext): TransformResult { + const diagnostics: Diagnostic[] = []; + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + + for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + const name = namedImport.getName(); + const newName = SIMPLE_RENAMES[name]; + if (newName) { + namedImport.setName(newName); + const alias = namedImport.getAliasNode(); + if (!alias) { + renameAllReferences(sourceFile, name, newName); + } + changesCount++; + } + } + } + + changesCount += handleErrorCodeSplit(sourceFile, diagnostics); + changesCount += handleRequestHandlerExtra(sourceFile, context, diagnostics); + changesCount += handleSchemaInput(sourceFile, context, diagnostics); + + return { changesCount, diagnostics }; + } +}; + +function handleErrorCodeSplit(sourceFile: SourceFile, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let errorCodeImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + + for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'ErrorCode') { + errorCodeImport = namedImport; + break; + } + } + if (errorCodeImport) break; + } + + if (!errorCodeImport) return 0; + + const errorCodeLocalName = errorCodeImport.getAliasNode()?.getText() ?? 'ErrorCode'; + + let needsProtocolErrorCode = false; + let needsSdkErrorCode = false; + + sourceFile.forEachDescendant(node => { + if (!Node.isPropertyAccessExpression(node)) return; + const expr = node.getExpression(); + if (!Node.isIdentifier(expr) || expr.getText() !== errorCodeLocalName) return; + + const member = node.getName(); + if (ERROR_CODE_SDK_MEMBERS.has(member)) { + needsSdkErrorCode = true; + node.getExpression().replaceWithText('SdkErrorCode'); + } else { + needsProtocolErrorCode = true; + node.getExpression().replaceWithText('ProtocolErrorCode'); + } + changesCount++; + }); + + if (changesCount > 0) { + const errorCodeImportDecl = errorCodeImport.getImportDeclaration(); + // Capture target module before removing the import, so we don't lose the original + // module specifier when ErrorCode was the only named import in the declaration. + const origModule = errorCodeImportDecl.getModuleSpecifierValue(); + const imp = + sourceFile.getImportDeclarations().find(i => { + const spec = i.getModuleSpecifierValue(); + return (spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server') && !i.isTypeOnly(); + }) ?? + sourceFile.getImportDeclarations().find(i => { + const spec = i.getModuleSpecifierValue(); + return spec === '@modelcontextprotocol/client' || spec === '@modelcontextprotocol/server'; + }); + const targetModule = imp?.getModuleSpecifierValue() ?? origModule ?? '@modelcontextprotocol/server'; + + errorCodeImport.remove(); + if ( + errorCodeImportDecl.getNamedImports().length === 0 && + !errorCodeImportDecl.getDefaultImport() && + !errorCodeImportDecl.getNamespaceImport() + ) { + errorCodeImportDecl.remove(); + } + + const newImports: string[] = []; + if (needsProtocolErrorCode) newImports.push('ProtocolErrorCode'); + if (needsSdkErrorCode) newImports.push('SdkErrorCode'); + + if (newImports.length > 0) { + const existingImp = sourceFile + .getImportDeclarations() + .find(i => i.getModuleSpecifierValue() === targetModule && !i.isTypeOnly()); + if (existingImp) { + const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName())); + const toAdd = newImports.filter(n => !existingNames.has(n)); + if (toAdd.length > 0) { + existingImp.addNamedImports(toAdd); + } + } else { + sourceFile.addImportDeclaration({ + moduleSpecifier: targetModule, + namedImports: newImports + }); + } + } + + diagnostics.push( + warning( + sourceFile.getFilePath(), + 1, + 'ErrorCode split into ProtocolErrorCode and SdkErrorCode. Verify the migration is correct.' + ) + ); + } + + return changesCount; +} + +function handleRequestHandlerExtra(sourceFile: SourceFile, context: TransformContext, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let extraImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + let extraImportDecl: (typeof imports)[0] | undefined; + + for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'RequestHandlerExtra') { + extraImport = namedImport; + extraImportDecl = imp; + break; + } + } + if (extraImport) break; + } + + if (!extraImport) return 0; + + const extraLocalName = extraImport.getAliasNode()?.getText() ?? 'RequestHandlerExtra'; + + const isClientFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; + }); + const isServerFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; + }); + + let defaultTarget: 'ServerContext' | 'ClientContext' = 'ServerContext'; + if (isClientFile && !isServerFile) { + defaultTarget = 'ClientContext'; + } else if (context.projectType === 'client') { + defaultTarget = 'ClientContext'; + } + + let needsServerContext = false; + let needsClientContext = false; + const strippedArgNames = new Set(); + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== extraLocalName) return; + + let target = defaultTarget; + const typeArgs = node.getTypeArguments(); + if (typeArgs.length > 0) { + const firstArgText = typeArgs[0]!.getText(); + if (SERVER_GENERIC_ARGS.has(firstArgText)) { + target = 'ServerContext'; + } else if (CLIENT_GENERIC_ARGS.has(firstArgText)) { + target = 'ClientContext'; + } + } + + if (target === 'ServerContext') needsServerContext = true; + if (target === 'ClientContext') needsClientContext = true; + + if (typeArgs.length > 0) { + for (const arg of typeArgs) { + const argText = arg.getText(); + if (SERVER_GENERIC_ARGS.has(argText) || CLIENT_GENERIC_ARGS.has(argText)) { + strippedArgNames.add(argText); + } + } + node.replaceWithText(target); + } else { + typeName.replaceWithText(target); + } + changesCount++; + }); + + if (changesCount > 0) { + const extraImportLine = extraImportDecl!.getStartLineNumber(); + extraImport.remove(); + if ( + extraImportDecl!.getNamedImports().length === 0 && + !extraImportDecl!.getDefaultImport() && + !extraImportDecl!.getNamespaceImport() + ) { + extraImportDecl!.remove(); + } + + const newImports: Array<{ name: string; target: string }> = []; + if (needsServerContext) newImports.push({ name: 'ServerContext', target: '@modelcontextprotocol/server' }); + if (needsClientContext) newImports.push({ name: 'ClientContext', target: '@modelcontextprotocol/client' }); + + for (const { name, target } of newImports) { + const existingImp = sourceFile.getImportDeclarations().find(i => i.getModuleSpecifierValue() === target && i.isTypeOnly()); + if (existingImp) { + const existingNames = new Set(existingImp.getNamedImports().map(n => n.getName())); + if (!existingNames.has(name)) { + existingImp.addNamedImports([name]); + } + } else { + const valueImp = sourceFile.getImportDeclarations().find(i => i.getModuleSpecifierValue() === target && !i.isTypeOnly()); + if (valueImp) { + const existingNames = new Set(valueImp.getNamedImports().map(n => n.getName())); + if (!existingNames.has(name)) { + valueImp.addNamedImports([name]); + } + } else { + sourceFile.addImportDeclaration({ + isTypeOnly: true, + moduleSpecifier: target, + namedImports: [name] + }); + } + } + } + + for (const argName of strippedArgNames) { + removeUnusedImport(sourceFile, argName, true); + } + + changesCount++; + + const targets = newImports.map(i => i.name).join(' and '); + diagnostics.push( + warning( + sourceFile.getFilePath(), + extraImportLine, + `RequestHandlerExtra renamed to ${targets}. Generic type arguments removed. Verify the migration is correct.` + ) + ); + } + + return changesCount; +} + +function handleSchemaInput(sourceFile: SourceFile, context: TransformContext, diagnostics: Diagnostic[]): number { + let changesCount = 0; + + const imports = sourceFile.getImportDeclarations(); + let schemaInputImport: ReturnType<(typeof imports)[0]['getNamedImports']>[0] | undefined; + let schemaInputImportDecl: (typeof imports)[0] | undefined; + + for (const imp of imports) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if (namedImport.getName() === 'SchemaInput') { + schemaInputImport = namedImport; + schemaInputImportDecl = imp; + break; + } + } + if (schemaInputImport) break; + } + + if (!schemaInputImport || !schemaInputImportDecl) return 0; + + const schemaInputLocalName = schemaInputImport.getAliasNode()?.getText() ?? 'SchemaInput'; + + sourceFile.forEachDescendant(node => { + if (!Node.isTypeReference(node)) return; + const typeName = node.getTypeName(); + if (!Node.isIdentifier(typeName) || typeName.getText() !== schemaInputLocalName) return; + + const typeArgs = node.getTypeArguments(); + if (typeArgs.length > 0) { + const argText = typeArgs[0]!.getText(); + node.replaceWithText(`StandardSchemaWithJSON.InferInput<${argText}>`); + } else { + node.replaceWithText('StandardSchemaWithJSON.InferInput'); + } + changesCount++; + }); + + if (changesCount > 0) { + schemaInputImport.remove(); + if ( + schemaInputImportDecl.getNamedImports().length === 0 && + !schemaInputImportDecl.getDefaultImport() && + !schemaInputImportDecl.getNamespaceImport() + ) { + schemaInputImportDecl.remove(); + } + + const isClientFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/client/') || spec === '@modelcontextprotocol/client'; + }); + const isServerFile = sourceFile.getImportDeclarations().some(i => { + const spec = i.getModuleSpecifierValue(); + return spec.includes('/server/') || spec === '@modelcontextprotocol/server'; + }); + const targetModule = resolveTypesPackage(context, isClientFile, isServerFile); + + const insertIndex = sourceFile.getImportDeclarations().length; + addOrMergeImport(sourceFile, targetModule, ['StandardSchemaWithJSON'], true, insertIndex); + changesCount++; + + diagnostics.push( + info( + sourceFile.getFilePath(), + 1, + 'SchemaInput replaced with StandardSchemaWithJSON.InferInput. Verify the migration is correct.' + ) + ); + } + + return changesCount; +} diff --git a/packages/codemod/src/runner.ts b/packages/codemod/src/runner.ts new file mode 100644 index 0000000..fa664b5 --- /dev/null +++ b/packages/codemod/src/runner.ts @@ -0,0 +1,132 @@ +import { Project } from 'ts-morph'; + +import type { Diagnostic, FileResult, Migration, RunnerOptions, RunnerResult } from './types.js'; +import { error } from './utils/diagnostics.js'; +import { updatePackageJson } from './utils/packageJsonUpdater.js'; +import { analyzeProject } from './utils/projectAnalyzer.js'; + +function escapeGlobPath(p: string): string { + return p.replaceAll(/[[\]{}()*?!@#]/g, String.raw`\$&`); +} + +export function run(migration: Migration, options: RunnerOptions): RunnerResult { + const context = analyzeProject(options.targetDir); + + let enabledTransforms = migration.transforms; + if (options.transforms) { + const validIds = new Set(migration.transforms.map(t => t.id)); + const unknown = options.transforms.filter(id => !validIds.has(id)); + if (unknown.length > 0) { + throw new Error( + `Unknown transform ID(s): ${unknown.join(', ')}. ` + + `Available: ${[...validIds].join(', ')}. Use --list to see all transforms.` + ); + } + enabledTransforms = migration.transforms.filter(t => options.transforms!.includes(t.id)); + } + + const project = new Project({ + tsConfigFilePath: undefined, + skipAddingFilesFromTsConfig: true, + compilerOptions: { + allowJs: true, + noEmit: true + } + }); + + const globPattern = `${escapeGlobPath(options.targetDir)}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`; + const ignorePatterns = [ + '**/node_modules/**', + '**/dist/**', + '**/.git/**', + '**/build/**', + '**/.next/**', + '**/.nuxt/**', + '**/coverage/**', + '**/__generated__/**', + '**/*.d.ts', + '**/*.d.mts', + '**/*.d.cts', + ...(options.ignore ?? []) + ]; + + const allPatterns = [globPattern]; + for (const ignore of ignorePatterns) { + allPatterns.push(`!${ignore}`); + } + project.addSourceFilesAtPaths(allPatterns); + + const sourceFiles = project.getSourceFiles().filter(sf => { + const fp = sf.getFilePath(); + if (fp.includes('/node_modules/') || fp.includes('/dist/')) return false; + if (fp.endsWith('.d.ts') || fp.endsWith('.d.mts') || fp.endsWith('.d.cts')) return false; + return true; + }); + const fileResults: FileResult[] = []; + const allDiagnostics: Diagnostic[] = []; + const allUsedPackages = new Set(); + let totalChanges = 0; + let filesChanged = 0; + + for (const sourceFile of sourceFiles) { + let fileChanges = 0; + const fileDiagnostics: Diagnostic[] = []; + const originalText = sourceFile.getFullText(); + + const fileUsedPackages = new Set(); + try { + for (const transform of enabledTransforms) { + const result = transform.apply(sourceFile, context); + fileChanges += result.changesCount; + fileDiagnostics.push(...result.diagnostics); + if (result.usedPackages) { + for (const pkg of result.usedPackages) { + fileUsedPackages.add(pkg); + } + } + } + for (const pkg of fileUsedPackages) { + allUsedPackages.add(pkg); + } + } catch (error_) { + const filePath = sourceFile.getFilePath(); + fileDiagnostics.length = 0; + fileDiagnostics.push(error(filePath, 1, `Transform failed: ${error_ instanceof Error ? error_.message : String(error_)}`)); + sourceFile.replaceWithText(originalText); + fileChanges = 0; + fileUsedPackages.clear(); + } + + if (fileChanges > 0 || fileDiagnostics.length > 0) { + if (fileChanges > 0) { + filesChanged++; + totalChanges += fileChanges; + } + fileResults.push({ + filePath: sourceFile.getFilePath(), + changes: fileChanges, + diagnostics: fileDiagnostics + }); + allDiagnostics.push(...fileDiagnostics); + } + } + + const hasImportsTransform = enabledTransforms.some(t => t.id === 'imports'); + const packageJsonChanges = hasImportsTransform + ? updatePackageJson(options.targetDir, allUsedPackages, options.dryRun ?? false) + : undefined; + + // Per-file mutations are atomic: if any transform fails, the file is rolled back to its + // original state and an error diagnostic is emitted. + if (!options.dryRun) { + project.saveSync(); + } + + return { + filesChanged, + totalChanges, + diagnostics: allDiagnostics, + fileResults, + packageJsonChanges + }; +} diff --git a/packages/codemod/src/types.ts b/packages/codemod/src/types.ts new file mode 100644 index 0000000..5c83202 --- /dev/null +++ b/packages/codemod/src/types.ts @@ -0,0 +1,65 @@ +import type { SourceFile } from 'ts-morph'; + +export enum DiagnosticLevel { + Error = 'error', + Warning = 'warning', + Info = 'info' +} + +export interface Diagnostic { + level: DiagnosticLevel; + file: string; + line: number; + message: string; + category?: 'v2-gap'; +} + +export interface TransformResult { + changesCount: number; + diagnostics: Diagnostic[]; + usedPackages?: Set; +} + +export interface Transform { + name: string; + id: string; + apply(sourceFile: SourceFile, context: TransformContext): TransformResult; +} + +export interface TransformContext { + projectType: 'client' | 'server' | 'both' | 'unknown'; +} + +export interface Migration { + name: string; + description: string; + transforms: Transform[]; +} + +export interface RunnerOptions { + targetDir: string; + dryRun?: boolean; + verbose?: boolean; + transforms?: string[]; + ignore?: string[]; +} + +export interface FileResult { + filePath: string; + changes: number; + diagnostics: Diagnostic[]; +} + +export interface PackageJsonChange { + added: string[]; + removed: string[]; + packageJsonPath: string; +} + +export interface RunnerResult { + filesChanged: number; + totalChanges: number; + diagnostics: Diagnostic[]; + fileResults: FileResult[]; + packageJsonChanges?: PackageJsonChange; +} diff --git a/packages/codemod/src/utils/astUtils.ts b/packages/codemod/src/utils/astUtils.ts new file mode 100644 index 0000000..897c026 --- /dev/null +++ b/packages/codemod/src/utils/astUtils.ts @@ -0,0 +1,33 @@ +import type { SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +export function renameAllReferences(sourceFile: SourceFile, oldName: string, newName: string): void { + sourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === oldName) { + const parent = node.getParent(); + if (!parent) return; + if (Node.isImportSpecifier(parent)) return; + if (Node.isExportSpecifier(parent)) { + if (parent.getAliasNode() === node) return; + if (!parent.getAliasNode()) parent.setAlias(oldName); + parent.getNameNode().replaceWithText(newName); + return; + } + if (Node.isPropertyAssignment(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === node) return; + if (Node.isPropertySignature(parent) && parent.getNameNode() === node) return; + if (Node.isMethodDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isMethodSignature(parent) && parent.getNameNode() === node) return; + if (Node.isPropertyDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isEnumMember(parent) && parent.getNameNode() === node) return; + if (Node.isBindingElement(parent) && parent.getPropertyNameNode() === node) return; + if (Node.isGetAccessorDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isSetAccessorDeclaration(parent) && parent.getNameNode() === node) return; + if (Node.isShorthandPropertyAssignment(parent)) { + parent.replaceWithText(`${oldName}: ${newName}`); + return; + } + node.replaceWithText(newName); + } + }); +} diff --git a/packages/codemod/src/utils/diagnostics.ts b/packages/codemod/src/utils/diagnostics.ts new file mode 100644 index 0000000..6b42db9 --- /dev/null +++ b/packages/codemod/src/utils/diagnostics.ts @@ -0,0 +1,29 @@ +import type { Diagnostic } from '../types.js'; +import { DiagnosticLevel } from '../types.js'; + +export function error(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Error, file, line, message }; +} + +export function warning(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Warning, file, line, message }; +} + +export function info(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Info, file, line, message }; +} + +export function v2Gap(file: string, line: number, message: string): Diagnostic { + return { level: DiagnosticLevel.Warning, file, line, message, category: 'v2-gap' }; +} + +const LEVEL_PREFIX: Record = { + [DiagnosticLevel.Error]: 'ERROR', + [DiagnosticLevel.Warning]: 'WARNING', + [DiagnosticLevel.Info]: 'INFO' +}; + +export function formatDiagnostic(d: Diagnostic): string { + const prefix = d.category === 'v2-gap' ? 'V2 GAP' : LEVEL_PREFIX[d.level]; + return ` ${d.file}:${d.line} - [${prefix}] ${d.message}`; +} diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts new file mode 100644 index 0000000..145c953 --- /dev/null +++ b/packages/codemod/src/utils/importUtils.ts @@ -0,0 +1,141 @@ +import type { ExportDeclaration, ImportDeclaration, SourceFile } from 'ts-morph'; +import { Node } from 'ts-morph'; + +const SDK_PREFIX = '@modelcontextprotocol/sdk'; + +const V2_PACKAGES = new Set([ + '@modelcontextprotocol/client', + '@modelcontextprotocol/server', + '@modelcontextprotocol/core', + '@modelcontextprotocol/node', + '@modelcontextprotocol/express' +]); + +export function isSdkSpecifier(specifier: string): boolean { + return specifier === SDK_PREFIX || specifier.startsWith(SDK_PREFIX + '/'); +} + +export function getSdkImports(sourceFile: SourceFile): ImportDeclaration[] { + return sourceFile.getImportDeclarations().filter(imp => { + return isSdkSpecifier(imp.getModuleSpecifierValue()); + }); +} + +export function getSdkExports(sourceFile: SourceFile): ExportDeclaration[] { + return sourceFile.getExportDeclarations().filter(exp => { + const specifier = exp.getModuleSpecifierValue(); + return specifier != null && isSdkSpecifier(specifier); + }); +} + +export function isTypeOnlyImport(imp: ImportDeclaration): boolean { + return imp.isTypeOnly(); +} + +export function addOrMergeImport( + sourceFile: SourceFile, + moduleSpecifier: string, + namedImports: string[], + isTypeOnly: boolean, + insertIndex: number +): void { + if (namedImports.length === 0) return; + + const existing = sourceFile.getImportDeclarations().find(imp => { + if (imp.getNamespaceImport()) return false; + return imp.getModuleSpecifierValue() === moduleSpecifier && imp.isTypeOnly() === isTypeOnly; + }); + + if (existing) { + const existingNames = new Set(existing.getNamedImports().map(n => n.getName())); + const newNames = namedImports.filter(n => !existingNames.has(n)); + if (newNames.length > 0) { + existing.addNamedImports(newNames); + } + } else { + const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); + sourceFile.insertImportDeclaration(clampedIndex, { + moduleSpecifier, + namedImports: [...new Set(namedImports)], + isTypeOnly + }); + } +} + +export function isAnyMcpSpecifier(specifier: string): boolean { + if (isSdkSpecifier(specifier)) return true; + if (V2_PACKAGES.has(specifier)) return true; + const secondSlash = specifier.indexOf('/', specifier.indexOf('/') + 1); + return secondSlash !== -1 && V2_PACKAGES.has(specifier.slice(0, secondSlash)); +} + +export function hasMcpImports(sourceFile: SourceFile): boolean { + return sourceFile.getImportDeclarations().some(imp => isAnyMcpSpecifier(imp.getModuleSpecifierValue())); +} + +export function isImportedFromMcp(sourceFile: SourceFile, symbolName: string): boolean { + return sourceFile.getImportDeclarations().some(imp => { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; + return imp.getNamedImports().some(n => { + const localName = n.getAliasNode()?.getText() ?? n.getName(); + return localName === symbolName; + }); + }); +} + +export function isOriginalNameImportedFromMcp(sourceFile: SourceFile, exportName: string): boolean { + return sourceFile.getImportDeclarations().some(imp => { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) return false; + return imp.getNamedImports().some(n => n.getName() === exportName); + }); +} + +export function resolveLocalImportName(sourceFile: SourceFile, exportName: string): string | undefined { + for (const imp of sourceFile.getImportDeclarations()) { + if (!isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const n of imp.getNamedImports()) { + if (n.getName() === exportName) { + return n.getAliasNode()?.getText() ?? exportName; + } + } + } + return undefined; +} + +export function resolveOriginalImportName(sourceFile: SourceFile, localName: string): string | undefined { + for (const imp of sourceFile.getImportDeclarations()) { + for (const n of imp.getNamedImports()) { + const alias = n.getAliasNode()?.getText(); + if (alias === localName) return n.getName(); + if (!alias && n.getName() === localName) return localName; + } + } + return undefined; +} + +export function removeUnusedImport(sourceFile: SourceFile, symbolName: string, onlyMcpImports?: boolean): void { + let referenceCount = 0; + sourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === symbolName) { + const parent = node.getParent(); + if (parent && !Node.isImportSpecifier(parent)) { + referenceCount++; + } + } + }); + + if (referenceCount === 0) { + for (const imp of sourceFile.getImportDeclarations()) { + if (onlyMcpImports && !isAnyMcpSpecifier(imp.getModuleSpecifierValue())) continue; + for (const namedImport of imp.getNamedImports()) { + if ((namedImport.getAliasNode()?.getText() ?? namedImport.getName()) === symbolName) { + namedImport.remove(); + if (imp.getNamedImports().length === 0 && !imp.getDefaultImport() && !imp.getNamespaceImport()) { + imp.remove(); + } + return; + } + } + } + } +} diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts new file mode 100644 index 0000000..96a4143 --- /dev/null +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +import { V2_PACKAGE_VERSIONS } from '../generated/versions.js'; +import type { PackageJsonChange } from '../types.js'; +import { findPackageJson } from './projectAnalyzer.js'; + +const V1_PACKAGE = '@modelcontextprotocol/sdk'; +const PRIVATE_PACKAGES = new Set(['@modelcontextprotocol/core']); + +function normalizeToRoot(pkg: string): string { + const secondSlash = pkg.indexOf('/', pkg.indexOf('/') + 1); + if (secondSlash === -1) return pkg; + return pkg.slice(0, secondSlash); +} + +function detectIndent(text: string): string { + const match = text.match(/\n([ \t]+)/); + return match ? match[1]! : ' '; +} + +export function updatePackageJson(targetDir: string, usedPackages: Set, dryRun: boolean): PackageJsonChange | undefined { + const pkgJsonPath = findPackageJson(targetDir); + if (!pkgJsonPath) return undefined; + + let raw: string; + let pkgJson: Record; + try { + raw = readFileSync(pkgJsonPath, 'utf8'); + pkgJson = JSON.parse(raw) as Record; + } catch { + return undefined; + } + const deps = pkgJson.dependencies as Record | undefined; + const devDeps = pkgJson.devDependencies as Record | undefined; + + const inDeps = deps !== undefined && V1_PACKAGE in deps; + const inDevDeps = devDeps !== undefined && V1_PACKAGE in devDeps; + if (!inDeps && !inDevDeps) return undefined; + + const packagesToAdd = [...new Set([...usedPackages].map(pkg => normalizeToRoot(pkg)))].filter( + pkg => !PRIVATE_PACKAGES.has(pkg) && pkg in V2_PACKAGE_VERSIONS + ); + + // Determine which section to add v2 packages to. + // If v1 SDK was in both, prefer dependencies. + const targetSection = inDeps ? 'dependencies' : 'devDependencies'; + + const added: string[] = []; + for (const pkg of packagesToAdd) { + const alreadyInDeps = deps !== undefined && pkg in deps; + const alreadyInDevDeps = devDeps !== undefined && pkg in devDeps; + if (alreadyInDeps || alreadyInDevDeps) continue; + + if (!pkgJson[targetSection]) { + pkgJson[targetSection] = {}; + } + (pkgJson[targetSection] as Record)[pkg] = V2_PACKAGE_VERSIONS[pkg]!; + added.push(pkg); + } + + if (inDeps) delete deps![V1_PACKAGE]; + if (inDevDeps) delete devDeps![V1_PACKAGE]; + const removed = [V1_PACKAGE]; + + if (!dryRun) { + const indent = detectIndent(raw); + const trailingNewline = raw.endsWith('\n'); + let output = JSON.stringify(pkgJson, null, indent); + if (trailingNewline) output += '\n'; + writeFileSync(pkgJsonPath, output); + } + + return { + added: added.toSorted(), + removed, + packageJsonPath: pkgJsonPath + }; +} diff --git a/packages/codemod/src/utils/projectAnalyzer.ts b/packages/codemod/src/utils/projectAnalyzer.ts new file mode 100644 index 0000000..daf4088 --- /dev/null +++ b/packages/codemod/src/utils/projectAnalyzer.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +import type { Diagnostic, TransformContext } from '../types.js'; +import { warning } from './diagnostics.js'; + +const PROJECT_ROOT_MARKERS = ['.git', 'node_modules']; + +export function findPackageJson(startDir: string): string | undefined { + let dir = path.resolve(startDir); + const root = path.parse(dir).root; + while (true) { + const candidate = path.join(dir, 'package.json'); + if (existsSync(candidate)) return candidate; + if (dir === root) return undefined; + if (PROJECT_ROOT_MARKERS.some(m => existsSync(path.join(dir, m)))) return undefined; + dir = path.dirname(dir); + } +} + +export function analyzeProject(targetDir: string): TransformContext { + const pkgJsonPath = findPackageJson(targetDir); + if (!pkgJsonPath) { + return { projectType: 'unknown' }; + } + + try { + const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); + const allDeps = { + ...pkgJson.dependencies, + ...pkgJson.devDependencies + }; + + const hasClient = '@modelcontextprotocol/client' in allDeps; + const hasServer = '@modelcontextprotocol/server' in allDeps; + + if (hasClient && hasServer) return { projectType: 'both' }; + if (hasClient) return { projectType: 'client' }; + if (hasServer) return { projectType: 'server' }; + return { projectType: 'unknown' }; + } catch { + return { projectType: 'unknown' }; + } +} + +export function resolveTypesPackage( + context: TransformContext, + fileHasClientImports: boolean, + fileHasServerImports: boolean, + diagnosticSink?: { filePath: string; line: number; diagnostics: Diagnostic[] } +): string { + if (fileHasClientImports && !fileHasServerImports) { + return '@modelcontextprotocol/client'; + } + if (fileHasServerImports && !fileHasClientImports) { + return '@modelcontextprotocol/server'; + } + if (context.projectType === 'client') { + return '@modelcontextprotocol/client'; + } + if (context.projectType === 'server') { + return '@modelcontextprotocol/server'; + } + if (diagnosticSink) { + diagnosticSink.diagnostics.push( + warning( + diagnosticSink.filePath, + diagnosticSink.line, + 'Could not determine project type (client vs server). Defaulting to @modelcontextprotocol/server. ' + + 'If this is a client-only project, adjust imports manually.' + ) + ); + } + return '@modelcontextprotocol/server'; +} diff --git a/packages/codemod/test/cli.test.ts b/packages/codemod/test/cli.test.ts new file mode 100644 index 0000000..18f1952 --- /dev/null +++ b/packages/codemod/test/cli.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { getMigration } from '../src/migrations/index.js'; +import { run } from '../src/runner.js'; +import { DiagnosticLevel } from '../src/types.js'; + +const migration = getMigration('v1-to-v2')!; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-cli-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('CLI diagnostic behavior', () => { + it('warnings do not produce errors-level diagnostics', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`, + `const transport = new SSEServerTransport();`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning); + const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + expect(warnings.length).toBeGreaterThan(0); + expect(errors.length).toBe(0); + }); + + it('emits info-level diagnostics for z.object() wrapping', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `server.tool('greet', 'Say hello', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + const infos = result.diagnostics.filter(d => d.level === DiagnosticLevel.Info); + expect(infos.length).toBeGreaterThan(0); + expect(infos.some(d => d.message.includes('z.object'))).toBe(true); + }); +}); + +describe('--transforms validation', () => { + it('throws on unknown transform IDs', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'test.ts'), `const x = 1;\n`); + + expect(() => run(migration, { targetDir: dir, transforms: ['import-paths', 'symbol-renames'] })).toThrow(/Unknown transform ID/); + }); + + it('error message lists the unknown IDs and available IDs', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'test.ts'), `const x = 1;\n`); + + expect(() => run(migration, { targetDir: dir, transforms: ['bogus'] })).toThrow(/bogus.*Available:/); + }); + + it('accepts valid transform IDs', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'test.ts'), + [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'e');`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir, transforms: ['symbols'] }); + expect(result.totalChanges).toBeGreaterThan(0); + }); +}); + +describe('.d.ts exclusion', () => { + it('skips .d.ts files', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'types.d.ts'), + [`import type { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export type E = McpError;`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(0); + }); + + it('skips .d.mts files', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'types.d.mts'), + [`import type { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export type E = McpError;`, ''].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(0); + }); +}); + +describe('CLI command declaration', () => { + it('v1-to-v2 migration is registered and has transforms', () => { + expect(migration).toBeDefined(); + expect(migration.transforms.length).toBeGreaterThan(0); + }); + + it('all transforms have an id and name', () => { + for (const t of migration.transforms) { + expect(t.id).toBeTruthy(); + expect(t.name).toBeTruthy(); + } + }); +}); + +describe('InMemoryTransport migration', () => { + it('InMemoryTransport import is rewritten to server package without v2-gap diagnostic', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'test-utils.ts'), + [`import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';`, `const t = new InMemoryTransport();`, ``].join( + '\n' + ) + ); + + const result = run(migration, { targetDir: dir }); + + const v2Gaps = result.diagnostics.filter(d => d.category === 'v2-gap'); + expect(v2Gaps.length).toBe(0); + + const output = readFileSync(path.join(dir, 'test-utils.ts'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + }); +}); diff --git a/packages/codemod/test/integration.test.ts b/packages/codemod/test/integration.test.ts new file mode 100644 index 0000000..737d3eb --- /dev/null +++ b/packages/codemod/test/integration.test.ts @@ -0,0 +1,655 @@ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { getMigration } from '../src/migrations/index.js'; +import { run } from '../src/runner.js'; +import { DiagnosticLevel } from '../src/types.js'; +import type { Migration, Transform } from '../src/types.js'; + +const migration = getMigration('v1-to-v2')!; + +function writePkgJson(dir: string, content: Record): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content, null, 2) + '\n'); +} + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-test-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('integration', () => { + it('applies all transforms to a realistic v1 file', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { CallToolRequestSchema, ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + ``, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `const transport = new StreamableHTTPServerTransport({});`, + ``, + `server.tool('greet', 'Say hello', { name: z.string() }, async ({ name }, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + ``, + `server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {`, + ` const id = extra.requestId;`, + ` return { content: [] };`, + `});`, + ``, + `const code = ErrorCode.InvalidParams;`, + `const timeout = ErrorCode.RequestTimeout;`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + expect(result.totalChanges).toBeGreaterThan(0); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + + // Import paths rewritten + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).toContain('@modelcontextprotocol/node'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + + // Symbol renames + body references updated + expect(output).toContain('NodeStreamableHTTPServerTransport'); + expect(output).toContain('new NodeStreamableHTTPServerTransport({})'); + expect(output).not.toMatch(/(? { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir, dryRun: true }); + + expect(result.totalChanges).toBeGreaterThan(0); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + expect(output).toBe(input); + }); + + it('skips files with no SDK imports', () => { + const dir = createTempDir(); + const input = `import express from 'express';\nconst app = express();\n`; + + writeFileSync(path.join(dir, 'app.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(0); + expect(result.totalChanges).toBe(0); + + const output = readFileSync(path.join(dir, 'app.ts'), 'utf8'); + expect(output).toBe(input); + }); + + it('processes multiple files independently', () => { + const dir = createTempDir(); + const serverFile = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + const clientFile = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + const plainFile = `const x = 1;\n`; + + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync(path.join(dir, 'src', 'server.ts'), serverFile); + writeFileSync(path.join(dir, 'src', 'client.ts'), clientFile); + writeFileSync(path.join(dir, 'src', 'utils.ts'), plainFile); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(2); + + const serverOutput = readFileSync(path.join(dir, 'src', 'server.ts'), 'utf8'); + expect(serverOutput).toContain('@modelcontextprotocol/server'); + + const clientOutput = readFileSync(path.join(dir, 'src', 'client.ts'), 'utf8'); + expect(clientOutput).toContain('@modelcontextprotocol/client'); + + const utilsOutput = readFileSync(path.join(dir, 'src', 'utils.ts'), 'utf8'); + expect(utilsOutput).toBe(plainFile); + }); + + it('recovers from transform errors and reports diagnostics', () => { + const dir = createTempDir(); + + // A valid file that should be transformed successfully + const validFile = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + + // A file that will trigger the failing transform + const brokenFile = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'broken', version: '1.0' });`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'valid.ts'), validFile); + writeFileSync(path.join(dir, 'broken.ts'), brokenFile); + + // Build a custom migration: real transforms + one that throws on 'broken' files + const failingTransform: Transform = { + name: 'failing', + id: 'failing', + apply(sourceFile) { + if (sourceFile.getFilePath().includes('broken')) { + throw new Error('Intentional failure for error-recovery test'); + } + return { changesCount: 0, diagnostics: [] }; + } + }; + + const testMigration: Migration = { + name: 'test-with-failing-transform', + description: 'Real transforms plus a failing transform for testing error recovery', + transforms: [...migration.transforms, failingTransform] + }; + + const result = run(testMigration, { targetDir: dir }); + + // The valid file should still be transformed correctly + const validOutput = readFileSync(path.join(dir, 'valid.ts'), 'utf8'); + expect(validOutput).toContain('@modelcontextprotocol/server'); + expect(validOutput).not.toContain('@modelcontextprotocol/sdk'); + + // The broken file should be rolled back to its original content + const brokenOutput = readFileSync(path.join(dir, 'broken.ts'), 'utf8'); + expect(brokenOutput).toBe(brokenFile); + + // An error-level diagnostic should mention the failure + const errorDiags = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + expect(errorDiags.length).toBeGreaterThanOrEqual(1); + expect(errorDiags.some(d => d.message.includes('Intentional failure'))).toBe(true); + + // The valid file should count as changed; the broken file should not + expect(result.filesChanged).toBeGreaterThanOrEqual(1); + }); + + it('rollback on transform error does not leak packages or diagnostics', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + writeFileSync(path.join(dir, 'broken.ts'), input); + + const leakyTransform: Transform = { + name: 'leaky', + id: 'imports', + apply(sourceFile) { + return { + changesCount: 1, + diagnostics: [ + { level: DiagnosticLevel.Warning, file: sourceFile.getFilePath(), line: 1, message: 'should not survive rollback' } + ], + usedPackages: new Set(['@modelcontextprotocol/phantom-pkg']) + }; + } + }; + + const failingTransform: Transform = { + name: 'failing', + id: 'failing', + apply() { + throw new Error('boom'); + } + }; + + const testMigration: Migration = { + name: 'test-rollback', + description: 'Tests that rollback cleans up all side effects', + transforms: [leakyTransform, failingTransform] + }; + + const result = run(testMigration, { targetDir: dir }); + + // File should be rolled back + const output = readFileSync(path.join(dir, 'broken.ts'), 'utf8'); + expect(output).toBe(input); + + // Only the error diagnostic should survive — not the warning from the reverted transform + const warnings = result.diagnostics.filter(d => d.level === DiagnosticLevel.Warning); + expect(warnings).toHaveLength(0); + const errors = result.diagnostics.filter(d => d.level === DiagnosticLevel.Error); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toContain('boom'); + + // Phantom package from the reverted transform should not leak into package.json + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/phantom-pkg'); + }); + + it('respects transform filter option', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `server.tool('ping', async () => ({ content: [] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + run(migration, { targetDir: dir, transforms: ['imports'] }); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + // Import paths should be rewritten + expect(output).toContain('@modelcontextprotocol/server'); + // But McpServer API should NOT be migrated (mcpserver-api transform was not selected) + expect(output).toContain("server.tool('ping'"); + // McpError should NOT be renamed (symbols transform was not selected) + expect(output).toContain('McpError'); + }); + + it('applies new transforms (removed APIs, SchemaInput, express middleware)', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer, schemaToJson, IsomorphicHeaders } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import type { SchemaInput } from '@modelcontextprotocol/sdk/types.js';`, + `import { StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';`, + ``, + `type Input = SchemaInput;`, + `const h: IsomorphicHeaders = {};`, + `if (error instanceof StreamableHTTPError) {}`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + + // SchemaInput rewritten + expect(output).toContain('StandardSchemaWithJSON.InferInput'); + expect(output).not.toContain('SchemaInput'); + + // IsomorphicHeaders replaced with global Headers + expect(output).toContain('const h: Headers'); + expect(output).not.toContain('IsomorphicHeaders'); + + // StreamableHTTPError renamed to SdkError + expect(output).toContain('instanceof SdkError'); + expect(output).not.toContain('StreamableHTTPError'); + + // schemaToJson removed (import gone) + expect(output).not.toContain('schemaToJson'); + + // hostHeaderValidation signature migrated + expect(output).toContain("hostHeaderValidation(['localhost'])"); + expect(output).not.toContain('allowedHosts'); + + // Diagnostics emitted + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + + it('updates package.json: removes v1 SDK and adds detected v2 packages', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + express: '^4.0.0' + } + }, + null, + 2 + ) + '\n' + ); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `const transport = new StreamableHTTPServerTransport({});`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/node'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/node']).toBeDefined(); + expect(pkgJson.dependencies['express']).toBe('^4.0.0'); + }); + + it('does not modify package.json in dry-run mode', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + writeFileSync(path.join(dir, 'server.ts'), `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`); + + const result = run(migration, { targetDir: dir, dryRun: true }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBe('^1.0.0'); + }); + + it('package.json: client-only project adds only @modelcontextprotocol/client', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'client.ts'), + [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/client'); + expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges!.added).not.toContain('@modelcontextprotocol/node'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('package.json: client + server project adds both packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync( + path.join(dir, 'src', 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + writeFileSync( + path.join(dir, 'src', 'client.ts'), + [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `const client = new Client({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/client'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('package.json: express middleware import adds @modelcontextprotocol/express', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/express'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/express']).toBeDefined(); + }); + + it('package.json: works when no package.json is present', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.filesChanged).toBe(1); + expect(result.packageJsonChanges).toBeUndefined(); + + const output = readFileSync(path.join(dir, 'server.ts'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + }); + + it('package.json: split import adds both /server and /node', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }); + writeFileSync( + path.join(dir, 'server.ts'), + [ + `import { StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const transport = new StreamableHTTPServerTransport({});`, + `const store: EventStore = {} as any;`, + `` + ].join('\n') + ); + + const result = run(migration, { targetDir: dir }); + + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/server'); + expect(result.packageJsonChanges!.added).toContain('@modelcontextprotocol/node'); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/server']).toBeDefined(); + expect(pkgJson.dependencies['@modelcontextprotocol/node']).toBeDefined(); + }); + + it('selective --transforms symbols does not modify package.json', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + writeFileSync( + path.join(dir, 'server.ts'), + [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'e');`, ``].join('\n') + ); + + const result = run(migration, { targetDir: dir, transforms: ['symbols'] }); + expect(result.totalChanges).toBeGreaterThan(0); + expect(result.packageJsonChanges).toBeUndefined(); + + const pkgJson = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); + expect(pkgJson.dependencies['@modelcontextprotocol/sdk']).toBe('^1.0.0'); + }); + + it('reports packageJsonChanges when only package.json is modified', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync(path.join(dir, 'src', 'utils.ts'), `const x = 1;\n`); + writeFileSync(path.join(dir, 'already-migrated.ts'), [`import { McpServer } from '@modelcontextprotocol/server';`, ``].join('\n')); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(0); + expect(result.packageJsonChanges).toBeDefined(); + expect(result.packageJsonChanges!.removed).toContain('@modelcontextprotocol/sdk'); + }); + + it('emits diagnostics for removed imports', () => { + const dir = createTempDir(); + const input = [ + `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';`, + `const transport = new SSEServerTransport();`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.ts'), input); + + const result = run(migration, { targetDir: dir }); + + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.level === DiagnosticLevel.Warning)).toBe(true); + }); + + it('transform ordering: critical dependencies are maintained', () => { + const ids = migration.transforms.map(t => t.id); + expect(ids.indexOf('imports')).toBeLessThan(ids.indexOf('symbols')); + expect(ids.indexOf('symbols')).toBeLessThan(ids.indexOf('removed-apis')); + expect(ids.indexOf('mcpserver-api')).toBeLessThan(ids.indexOf('context')); + expect(ids.at(-1)).toBe('mock-paths'); + }); + + it('processes .mts files', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.mts'), input); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(1); + const output = readFileSync(path.join(dir, 'server.mts'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('processes .js files', () => { + const dir = createTempDir(); + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'server.js'), input); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(1); + const output = readFileSync(path.join(dir, 'server.js'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites InMemoryTransport to server package by default', () => { + const dir = createTempDir(); + const input = [ + `import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';`, + `const t = new InMemoryTransport();`, + `` + ].join('\n'); + + writeFileSync(path.join(dir, 'test-utils.ts'), input); + + const result = run(migration, { targetDir: dir }); + expect(result.filesChanged).toBe(1); + + const output = readFileSync(path.join(dir, 'test-utils.ts'), 'utf8'); + expect(output).toContain('@modelcontextprotocol/server'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + expect(output).toContain('InMemoryTransport'); + + const v2Gaps = result.diagnostics.filter(d => d.category === 'v2-gap'); + expect(v2Gaps.length).toBe(0); + }); +}); diff --git a/packages/codemod/test/packageJsonUpdater.test.ts b/packages/codemod/test/packageJsonUpdater.test.ts new file mode 100644 index 0000000..27227ef --- /dev/null +++ b/packages/codemod/test/packageJsonUpdater.test.ts @@ -0,0 +1,299 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { updatePackageJson } from '../src/utils/packageJsonUpdater.js'; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-pkgjson-')); + return tempDir; +} + +function writePkgJson(dir: string, content: Record, indent: string | number = 2): void { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content, null, indent) + '\n'); +} + +function readPkgJson(dir: string): Record { + return JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')); +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('updatePackageJson', () => { + it('removes v1 SDK from dependencies and adds v2 packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + express: '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + expect(result).toBeDefined(); + expect(result!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(deps['express']).toBe('^4.0.0'); + }); + + it('removes v1 SDK from devDependencies and adds v2 packages there', () => { + const dir = createTempDir(); + writePkgJson(dir, { + devDependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/client']), false); + + expect(result).toBeDefined(); + const pkg = readPkgJson(dir); + const devDeps = pkg.devDependencies as Record; + expect(devDeps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(devDeps['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('removes v1 SDK from both sections, adds v2 to dependencies only', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + }, + devDependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + expect(result).toBeDefined(); + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + const devDeps = pkg.devDependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(devDeps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(devDeps['@modelcontextprotocol/server']).toBeUndefined(); + }); + + it('skips v2 packages that are already present', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + '@modelcontextprotocol/server': '^2.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node']), false); + + expect(result).toBeDefined(); + expect(result!.added).toContain('@modelcontextprotocol/node'); + expect(result!.added).not.toContain('@modelcontextprotocol/server'); + }); + + it('returns undefined when no package.json exists', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + + const result = updatePackageJson(path.join(dir, 'src'), new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('returns undefined when v1 SDK is not in package.json', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + express: '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('does not write file in dry-run mode', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), true); + + expect(result).toBeDefined(); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBe('^1.0.0'); + expect(deps['@modelcontextprotocol/server']).toBeUndefined(); + }); + + it('filters out @modelcontextprotocol/core (private package)', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/core', '@modelcontextprotocol/server']), false); + + expect(result).toBeDefined(); + expect(result!.added).not.toContain('@modelcontextprotocol/core'); + expect(result!.added).toContain('@modelcontextprotocol/server'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/core']).toBeUndefined(); + }); + + it('preserves 4-space indentation', () => { + const dir = createTempDir(); + writePkgJson( + dir, + { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }, + 4 + ); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const raw = readFileSync(path.join(dir, 'package.json'), 'utf8'); + expect(raw).toContain(' "dependencies"'); + }); + + it('preserves trailing newline', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const raw = readFileSync(path.join(dir, 'package.json'), 'utf8'); + expect(raw.endsWith('\n')).toBe(true); + expect(raw.endsWith('\n\n')).toBe(false); + }); + + it('removes v1 SDK even when no v2 packages are detected', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0', + express: '^4.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(), false); + + expect(result).toBeDefined(); + expect(result!.removed).toContain('@modelcontextprotocol/sdk'); + expect(result!.added).toEqual([]); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/sdk']).toBeUndefined(); + expect(deps['express']).toBe('^4.0.0'); + }); + + it('version strings have caret range format', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/server']).toMatch(/^\^/); + }); + + it('returns undefined for malformed package.json', () => { + const dir = createTempDir(); + writeFileSync(path.join(dir, 'package.json'), '{ invalid json }'); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/server']), false); + expect(result).toBeUndefined(); + }); + + it('normalizes subpath packages to root before adding to package.json', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/client/stdio']), false); + + expect(result).toBeDefined(); + expect(result!.added).toContain('@modelcontextprotocol/client'); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/client']).toBeDefined(); + }); + + it('deduplicates root and subpath packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson(dir, new Set(['@modelcontextprotocol/client', '@modelcontextprotocol/client/stdio']), false); + + expect(result).toBeDefined(); + expect(result!.added.filter(p => p === '@modelcontextprotocol/client')).toHaveLength(1); + }); + + it('adds multiple v2 packages', () => { + const dir = createTempDir(); + writePkgJson(dir, { + dependencies: { + '@modelcontextprotocol/sdk': '^1.0.0' + } + }); + + const result = updatePackageJson( + dir, + new Set(['@modelcontextprotocol/server', '@modelcontextprotocol/node', '@modelcontextprotocol/express']), + false + ); + + expect(result).toBeDefined(); + expect(result!.added).toEqual(['@modelcontextprotocol/express', '@modelcontextprotocol/node', '@modelcontextprotocol/server']); + + const pkg = readPkgJson(dir); + const deps = pkg.dependencies as Record; + expect(deps['@modelcontextprotocol/server']).toBeDefined(); + expect(deps['@modelcontextprotocol/node']).toBeDefined(); + expect(deps['@modelcontextprotocol/express']).toBeDefined(); + }); +}); diff --git a/packages/codemod/test/projectAnalyzer.test.ts b/packages/codemod/test/projectAnalyzer.test.ts new file mode 100644 index 0000000..0f69eac --- /dev/null +++ b/packages/codemod/test/projectAnalyzer.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { analyzeProject } from '../src/utils/projectAnalyzer.js'; + +let tempDir: string; + +function createTempDir(): string { + tempDir = mkdtempSync(path.join(tmpdir(), 'mcp-codemod-analyzer-')); + return tempDir; +} + +afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('analyzeProject', () => { + it('returns unknown when no package.json exists', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, '.git'), { recursive: true }); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + + const result = analyzeProject(path.join(dir, 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('finds package.json in parent directory', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'src'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'src')); + expect(result.projectType).toBe('client'); + }); + + it('finds package.json multiple levels up', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'src', 'lib', 'utils'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/server': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'src', 'lib', 'utils')); + expect(result.projectType).toBe('server'); + }); + + it('stops walking at .git boundary', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'project', 'src'), { recursive: true }); + mkdirSync(path.join(dir, 'project', '.git'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'project', 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('stops walking at node_modules boundary', () => { + const dir = createTempDir(); + mkdirSync(path.join(dir, 'project', 'src'), { recursive: true }); + mkdirSync(path.join(dir, 'project', 'node_modules'), { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/client': '^2.0.0' } + }) + ); + + const result = analyzeProject(path.join(dir, 'project', 'src')); + expect(result.projectType).toBe('unknown'); + }); + + it('detects both client and server dependencies', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { + '@modelcontextprotocol/client': '^2.0.0', + '@modelcontextprotocol/server': '^2.0.0' + } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('both'); + }); + + it('finds package.json at targetDir itself', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/server': '^2.0.0' } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('server'); + }); + + it('returns unknown for v1 SDK package (falls through to per-file resolution)', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + dependencies: { '@modelcontextprotocol/sdk': '^1.0.0' } + }) + ); + + const result = analyzeProject(dir); + expect(result.projectType).toBe('unknown'); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts new file mode 100644 index 0000000..ee3798c --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/contextTypes.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { contextTypesTransform } from '../../../src/migrations/v1-to-v2/transforms/contextTypes.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +const MCP_IMPORT = `import { McpServer } from '@modelcontextprotocol/server';\n`; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + code); + contextTypesTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('context-types transform', () => { + it('renames extra parameter to ctx in setRequestHandler', () => { + const input = [`server.setRequestHandler('tools/call', async (request, extra) => {`, ` return { content: [] };`, `});`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('(request, ctx)'); + expect(result).not.toContain('extra'); + }); + + it('rewrites extra.signal to ctx.mcpReq.signal', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + }); + + it('rewrites extra.requestId to ctx.mcpReq.id', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const id = extra.requestId;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.id'); + }); + + it('rewrites extra.sendNotification to ctx.mcpReq.notify', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` await extra.sendNotification({ method: 'test', params: {} });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.notify('); + }); + + it('rewrites extra.sendRequest to ctx.mcpReq.send', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` await extra.sendRequest({ method: 'test', params: {} });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.send('); + }); + + it('rewrites extra.authInfo to ctx.http?.authInfo', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const auth = extra.authInfo;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.http?.authInfo'); + }); + + it('rewrites extra.taskStore to ctx.task?.store', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const store = extra.taskStore;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.task?.store'); + }); + + it('does not touch non-extra parameters', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, context) => {`, + ` const s = context.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('context.signal'); + expect(result).not.toContain('ctx'); + }); + + it('does not rewrite properties that are prefixes of other properties', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` const h = extra.signalHandler;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).toContain('ctx.signalHandler'); + expect(result).not.toContain('ctx.mcpReq.signalHandler'); + }); + + it('emits warning when context parameter is destructured in body', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const { signal, authInfo } = extra;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Destructuring'); + }); + + it('emits warning when context parameter is destructured in signature', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, { signal, authInfo }) => {`, + ` if (signal.aborted) return;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Destructuring'); + expect(result.diagnostics[0]!.message).toContain('signal'); + }); + + it('works with registerTool callbacks', () => { + const input = [ + `server.registerTool('test', {}, async (args, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + }); + + it('does not transform files without MCP imports', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + + it('emits warning when another parameter is already named ctx', () => { + const input = [ + `server.setRequestHandler('tools/call', async (ctx, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('another parameter'); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + + it('emits warning when ctx variable already exists in scope', () => { + const input = [ + `const ctx = getApplicationContext();`, + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` console.log(ctx.appName);`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('ctx'); + expect(result.diagnostics[0]!.message).toContain('already referenced'); + expect(sourceFile.getFullText()).toContain('extra.signal'); + }); + + it('handles optional chaining on context properties', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const s = extra?.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).not.toContain('extra'); + }); + + it('renames extra to ctx in 2-arg tool(name, callback) calls', () => { + const input = [ + `server.tool('greet', async (request, extra) => {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('async (request, ctx)'); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).not.toContain('extra'); + }); + + it('renames extra to ctx in setNotificationHandler callbacks', () => { + const input = [ + `server.setNotificationHandler('notifications/cancelled', (notification, extra) => {`, + ` const s = extra.sessionId;`, + ` console.log(notification);`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('(notification, ctx)'); + expect(result).toContain('ctx.sessionId'); + expect(result).not.toContain('extra'); + }); + + it('does not inflate change count for identity mappings like sessionId', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const id = extra.sessionId;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(1); + expect(sourceFile.getFullText()).toContain('ctx.sessionId'); + }); + + it('renames extra to ctx even when nested arrow function has its own ctx param', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const items = [1, 2, 3];`, + ` const mapped = items.map((item, ctx) => ctx + item);`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('(request, ctx)'); + expect(result).toContain('items.map((item, ctx) => ctx + item)'); + }); + + it('still warns when ctx is a free variable from outer scope', () => { + const input = [ + `const ctx = { custom: true };`, + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` console.log(ctx.custom);`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = contextTypesTransform.apply(sourceFile, ctx); + expect(result.diagnostics.some(d => d.message.includes('already referenced'))).toBe(true); + expect(sourceFile.getFullText()).toContain('extra'); + }); + + it('renames extra to ctx and remaps properties in registerToolTask handler object', () => { + const input = [ + `server.experimental.tasks.registerToolTask('my-task', { schema: {} }, {`, + ` createTask: async (args, extra) => {`, + ` const s = extra.signal;`, + ` const store = extra.taskStore;`, + ` return { content: [] };`, + ` },`, + ` getTask: async (args, extra) => {`, + ` const auth = extra.authInfo;`, + ` return { content: [] };`, + ` },`, + ` getTaskResult: async (args, extra) => {`, + ` await extra.sendNotification({ method: 'test', params: {} });`, + ` return { content: [] };`, + ` },`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('extra'); + expect(result).toContain('(args, ctx)'); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).toContain('ctx.task?.store'); + expect(result).toContain('ctx.http?.authInfo'); + expect(result).toContain('ctx.mcpReq.notify('); + }); + + it('handles registerToolTask with shorthand method syntax', () => { + const input = [ + `server.registerToolTask('my-task', {}, {`, + ` async createTask(args, extra) {`, + ` const s = extra.signal;`, + ` return { content: [] };`, + ` },`, + ` async getTask(args, extra) {`, + ` return { content: [] };`, + ` },`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('extra'); + expect(result).toContain('(args, ctx)'); + expect(result).toContain('ctx.mcpReq.signal'); + }); + + it('does not rename "extra" inside string literals', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const msg = 'this is extra info';`, + ` const s = extra.signal;`, + ` return { content: [{ type: 'text', text: msg }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'this is extra info'"); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).toContain('(request, ctx)'); + }); + + it('does not rename "extra" as property name on unrelated object', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const meta = request.params._meta;`, + ` if (meta?.extra) { console.log(meta.extra); }`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('meta?.extra'); + expect(result).toContain('meta.extra'); + expect(result).toContain('ctx.mcpReq.signal'); + expect(result).not.toContain('meta?.ctx'); + expect(result).not.toContain('meta.ctx'); + }); + + it('does not rename "extra" in shorthand property assignment', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` helper({ request, extra });`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ request, extra }'); + expect(result).not.toContain('{ request, ctx }'); + expect(result).toContain('(request, ctx)'); + }); + + it('does not rename "extra" as binding element property name', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` const { extra: val } = unrelatedObj;`, + ` const s = extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ extra: val }'); + expect(result).not.toContain('{ ctx: val }'); + expect(result).toContain('ctx.mcpReq.signal'); + }); + + it('rewrites typeof ctx.sendRequest in type positions', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` type Send = Parameters;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('typeof ctx.mcpReq.send'); + expect(result).not.toContain('typeof ctx.sendRequest'); + expect(result).not.toContain('extra'); + }); + + it('rewrites typeof ctx.signal in type positions', () => { + const input = [ + `server.setRequestHandler('tools/call', async (request, extra) => {`, + ` type Sig = typeof extra.signal;`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('typeof ctx.mcpReq.signal'); + expect(result).not.toContain('typeof ctx.signal'); + expect(result).not.toContain('extra'); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts new file mode 100644 index 0000000..35182e4 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/expressMiddleware.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { expressMiddlewareTransform } from '../../../src/migrations/v1-to-v2/transforms/expressMiddleware.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string) { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + const result = expressMiddlewareTransform.apply(sourceFile, ctx); + return { text: sourceFile.getFullText(), result }; +} + +describe('express-middleware transform', () => { + it('rewrites hostHeaderValidation({ allowedHosts: [...] }) to hostHeaderValidation([...])', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost', '127.0.0.1'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); + expect(text).not.toContain('allowedHosts'); + expect(result.changesCount).toBe(1); + }); + + it('preserves calls that already use array syntax', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation(['localhost', '127.0.0.1']));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("hostHeaderValidation(['localhost', '127.0.0.1'])"); + expect(result.changesCount).toBe(0); + }); + + it('handles variable references in allowedHosts', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `const hosts = ['localhost'];`, + `app.use(hostHeaderValidation({ allowedHosts: hosts }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation(hosts)'); + expect(text).not.toContain('allowedHosts'); + expect(result.changesCount).toBe(1); + }); + + it('does not modify calls with non-object arguments', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation(someVariable));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation(someVariable)'); + expect(result.changesCount).toBe(0); + }); + + it('does not modify calls with no arguments', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation());`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('hostHeaderValidation()'); + expect(result.changesCount).toBe(0); + }); + + it('is idempotent', () => { + const input = [ + `import { hostHeaderValidation } from '@modelcontextprotocol/express';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + + it('does not modify calls when hostHeaderValidation is not from MCP', () => { + const input = [ + `import { hostHeaderValidation } from './my-middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(text).toContain("{ allowedHosts: ['localhost'] }"); + }); + + it('applies transform when hostHeaderValidation is aliased', () => { + const input = [ + `import { hostHeaderValidation as hhv } from '@modelcontextprotocol/express';`, + `app.use(hhv({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(result.changesCount).toBe(1); + expect(text).toContain("hhv(['localhost'])"); + expect(text).not.toContain('allowedHosts'); + }); + + it('does not modify non-MCP hostHeaderValidation even when other MCP imports exist', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/server';`, + `import { hostHeaderValidation } from './my-middleware.js';`, + `app.use(hostHeaderValidation({ allowedHosts: ['localhost'] }));`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(text).toContain("{ allowedHosts: ['localhost'] }"); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts new file mode 100644 index 0000000..f7b4815 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/handlerRegistration.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { handlerRegistrationTransform } from '../../../src/migrations/v1-to-v2/transforms/handlerRegistration.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + handlerRegistrationTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('handler-registration transform', () => { + it('replaces CallToolRequestSchema with method string', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tools/call'"); + expect(result).not.toContain('CallToolRequestSchema'); + }); + + it('replaces notification schema with method string', () => { + const input = [ + `import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => {`, + ` console.log(notification);`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/message'"); + expect(result).not.toContain('LoggingMessageNotificationSchema'); + }); + + it('removes unused schema import after replacement', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toContain('CallToolRequestSchema'); + }); + + it('keeps import if schema is referenced elsewhere', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + `console.log(CallToolRequestSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tools/call'"); + expect(result).toContain('import { CallToolRequestSchema }'); + }); + + it('is idempotent', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('handles multiple schema replacements in one file', () => { + const input = [ + `import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`, + `server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'tools/call'"); + expect(result).toContain("'tools/list'"); + }); + + it('does not replace schema identifiers from non-MCP packages', () => { + const input = [ + `import { CallToolRequestSchema } from './local-schemas.js';`, + `server.setRequestHandler(CallToolRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('CallToolRequestSchema'); + expect(result).not.toContain("'tools/call'"); + }); + + it('does not rewrite local import when aliased MCP import has same export name', () => { + const input = [ + `import { CallToolRequestSchema } from './local-schemas.js';`, + `import { CallToolRequestSchema as McpSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CallToolRequestSchema, async () => ({ content: [] }));`, + `validateSchema(McpSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("from './local-schemas.js'"); + expect(result).toContain('setRequestHandler(CallToolRequestSchema'); + expect(result).not.toContain("'tools/call'"); + }); + + it('replaces ListRootsRequestSchema with method string', () => { + const input = [ + `import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: [] }));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'roots/list'"); + expect(result).not.toContain('ListRootsRequestSchema'); + }); + + it('replaces RootsListChangedNotificationSchema with method string', () => { + const input = [ + `import { RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'notifications/roots/list_changed'"); + expect(result).not.toContain('RootsListChangedNotificationSchema'); + }); + + it('handles aliased schema imports', () => { + const input = [ + `import { CallToolRequestSchema as CTRS } from '@modelcontextprotocol/sdk/types.js';`, + `server.setRequestHandler(CTRS, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("'tools/call'"); + expect(result).not.toContain('CTRS'); + }); + + it('does not modify files with no imports at all', () => { + const input = [`server.setRequestHandler(SomeSchema, async (req) => ({ content: [] }));`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('SomeSchema'); + expect(result).not.toContain("'tools/call'"); + }); + + it('emits diagnostic for custom method schema (not in spec map)', () => { + const input = [ + `const AcmeSearch = z.object({ method: z.literal('acme/search'), params: z.object({ query: z.string() }) });`, + `server.setRequestHandler(AcmeSearch, async (request) => {`, + ` return { items: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = handlerRegistrationTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.message).toContain('Custom method handler'); + expect(result.diagnostics[0]!.message).toContain('AcmeSearch'); + expect(result.diagnostics[0]!.message).toContain('3-arg form'); + }); + + it('emits diagnostic for custom notification schema', () => { + const input = [ + `const CustomNotification = z.object({ method: z.literal('acme/notify') });`, + `server.setNotificationHandler(CustomNotification, async () => {});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = handlerRegistrationTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.message).toContain('Custom method handler'); + expect(result.diagnostics[0]!.message).toContain('setNotificationHandler'); + expect(result.diagnostics[0]!.message).toContain('CustomNotification'); + }); + + it('replaces ListTasksRequestSchema with method string', () => { + const input = [ + `import { ListTasksRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(ListTasksRequestSchema, async (request) => {`, + ` return { tasks: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/list'"); + expect(result).not.toContain('ListTasksRequestSchema'); + }); + + it('replaces GetTaskRequestSchema with method string', () => { + const input = [ + `import { GetTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(GetTaskRequestSchema, async (request) => {`, + ` return { taskId: '1', status: 'completed' };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/get'"); + expect(result).not.toContain('GetTaskRequestSchema'); + }); + + it('replaces CancelTaskRequestSchema with method string', () => { + const input = [ + `import { CancelTaskRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(CancelTaskRequestSchema, async (request) => {`, + ` return {};`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/cancel'"); + expect(result).not.toContain('CancelTaskRequestSchema'); + }); + + it('replaces GetTaskPayloadRequestSchema with method string', () => { + const input = [ + `import { GetTaskPayloadRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setRequestHandler(GetTaskPayloadRequestSchema, async (request) => {`, + ` return { content: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setRequestHandler('tasks/result'"); + expect(result).not.toContain('GetTaskPayloadRequestSchema'); + }); + + it('replaces TaskStatusNotificationSchema with method string', () => { + const input = [ + `import { TaskStatusNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setNotificationHandler(TaskStatusNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/tasks/status'"); + expect(result).not.toContain('TaskStatusNotificationSchema'); + }); + + it('replaces ElicitationCompleteNotificationSchema with method string', () => { + const input = [ + `import { ElicitationCompleteNotificationSchema } from '@modelcontextprotocol/sdk/types.js';`, + `client.setNotificationHandler(ElicitationCompleteNotificationSchema, async () => {});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("setNotificationHandler('notifications/elicitation/complete'"); + expect(result).not.toContain('ElicitationCompleteNotificationSchema'); + }); + + it('does not emit diagnostic when first arg is a string literal (v2 style)', () => { + const input = [`server.setRequestHandler('tools/call', async (request) => {`, ` return { content: [] };`, `});`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = handlerRegistrationTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBe(0); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts new file mode 100644 index 0000000..7068919 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { importPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/importPaths.js'; +import type { TransformContext } from '../../../src/types.js'; + +function applyTransform(code: string, context: TransformContext = { projectType: 'both' }): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + importPathsTransform.apply(sourceFile, context); + return sourceFile.getFullText(); +} + +describe('import-paths transform', () => { + it('rewrites client imports to @modelcontextprotocol/client', () => { + const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('Client'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites server imports to @modelcontextprotocol/server', () => { + const input = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('McpServer'); + }); + + it('consolidates multiple SDK imports to same v2 package', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('Client'); + expect(result).toContain('StreamableHTTPClientTransport'); + const importLines = result.split('\n').filter(l => l.includes('@modelcontextprotocol/client')); + expect(importLines.length).toBe(1); + }); + + it('rewrites server streamableHttp to @modelcontextprotocol/node with rename', () => { + const input = `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/node"`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).not.toMatch(/(? { + const input = `import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';\n`; + const ctx: TransformContext = { projectType: 'client' }; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + expect(sourceFile.getFullText()).not.toContain('WebSocketClientTransport'); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('WebSocketClientTransport'); + }); + + it('removes SSE server import with warning', () => { + const input = `import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';\n`; + const ctx: TransformContext = { projectType: 'server' }; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('SSE server transport'); + }); + + it('resolves sdk/types.js based on sibling client imports', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('CallToolResultSchema'); + }); + + it('resolves sdk/types.js based on sibling server imports', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + }); + + it('preserves type-only imports separately', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'client' }); + expect(result).toContain('import {'); + expect(result).toContain('import type {'); + }); + + it('is idempotent', () => { + const input = `import { Client } from '@modelcontextprotocol/sdk/client/index.js';\n`; + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('skips files with no SDK imports', () => { + const input = `import { something } from 'other-package';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'both' }); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toBe(input); + }); + + it('rewrites middleware import to @modelcontextprotocol/express', () => { + const input = `import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/express"`); + }); + + it('renames body references when renamedSymbols applies', () => { + const input = [ + `import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const transport = new StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('new NodeStreamableHTTPServerTransport({})'); + expect(result).not.toMatch(/(? { + const input = [ + `import { Client as MCPClient } from '@modelcontextprotocol/sdk/client/index.js';`, + `const c = new MCPClient({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('MCPClient'); + expect(result).toContain('@modelcontextprotocol/client'); + expect(result).toContain('new MCPClient({})'); + }); + + it('preserves namespace imports by rewriting module specifier', () => { + const input = [`import * as types from '@modelcontextprotocol/sdk/types.js';`, `const x: types.Tool = {};`, ''].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('import * as types'); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('types.Tool'); + }); + + it('preserves default imports by rewriting module specifier', () => { + const input = [`import sdk from '@modelcontextprotocol/sdk/types.js';`, `const x = sdk.foo;`, ''].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain('import sdk'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + + it('handles aliased renamedSymbols correctly', () => { + const input = [ + `import { StreamableHTTPServerTransport as SHST } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const t = new SHST({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport as SHST'); + expect(result).toContain('new SHST({})'); + expect(result).toContain('@modelcontextprotocol/node'); + }); + + it('splits streamableHttp import: transport to /node, types to /server', () => { + const input = [ + `import { StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('EventStore'); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('splits streamableHttp type import: transport to /node, types to /server', () => { + const input = [ + `import type { StreamableHTTPServerTransport, EventStore, StreamId } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('EventStore'); + expect(result).toContain('StreamId'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + + it('rewrites client stdio to @modelcontextprotocol/client/stdio subpath', () => { + const input = `import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/client/stdio"`); + expect(result).toContain('StdioClientTransport'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('routes all client stdio symbols to /stdio subpath', () => { + const input = [ + `import { StdioClientTransport, DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StdioClientTransport'); + expect(result).toContain('DEFAULT_INHERITED_ENV_VARS'); + expect(result).toContain('getDefaultEnvironment'); + expect(result).toContain('@modelcontextprotocol/client/stdio'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('routes type-only StdioServerParameters to /stdio subpath', () => { + const input = `import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';\n`; + const result = applyTransform(input); + expect(result).toContain('StdioServerParameters'); + expect(result).toContain('@modelcontextprotocol/client/stdio'); + }); + + it('rewrites server stdio to @modelcontextprotocol/server/stdio subpath', () => { + const input = `import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\n`; + const result = applyTransform(input); + expect(result).toContain(`from "@modelcontextprotocol/server/stdio"`); + expect(result).toContain('StdioServerTransport'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('preserves alias for client stdio import and routes to subpath', () => { + const input = [ + `import { StdioClientTransport as T } from '@modelcontextprotocol/sdk/client/stdio.js';`, + `const transport = new T({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/client/stdio'); + expect(result).toContain('StdioClientTransport as T'); + }); + + it('emits warning for namespace import with renamedSymbols', () => { + const input = [ + `import * as transport from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + `const t = new transport.StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(sourceFile.getFullText()).toContain('import * as transport'); + expect(sourceFile.getFullText()).toContain('@modelcontextprotocol/server'); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.message.includes('renamed') && d.message.includes('StreamableHTTPServerTransport'))).toBe( + true + ); + }); + + it('rewrites re-export with renamedSymbols and preserves public name', () => { + const input = `export { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\n`; + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/node'); + expect(result).toContain('NodeStreamableHTTPServerTransport as StreamableHTTPServerTransport'); + }); + + it('removes auth imports with warning', () => { + const input = `import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('auth router removed'); + }); + + it('handles per-specifier type modifiers', () => { + const input = [ + `import { McpServer, type ServerContext } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const s = new McpServer({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toMatch(/import\s*\{[^}]*McpServer[^}]*\}\s*from\s*['"]@modelcontextprotocol\/server['"]/); + expect(result).toMatch(/import\s+type\s*\{[^}]*ServerContext[^}]*\}\s*from\s*['"]@modelcontextprotocol\/server['"]/); + }); + + it('does not crash when value import merges into existing import', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/client';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + `const c = new Client({});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'client' }); + expect(result.changesCount).toBeGreaterThan(0); + const output = sourceFile.getFullText(); + expect(output).toContain('@modelcontextprotocol/client'); + expect(output).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('applies SIMPLE_RENAMES to re-export specifiers', () => { + const input = `export { McpError, ResourceReference } from '@modelcontextprotocol/sdk/types.js';\n`; + const result = applyTransform(input); + expect(result).toContain('ProtocolError as McpError'); + expect(result).toContain('ResourceTemplateReference as ResourceReference'); + expect(result).toContain('@modelcontextprotocol/server'); + }); + + it('emits warning for re-exported ErrorCode', () => { + const input = `export { ErrorCode } from '@modelcontextprotocol/sdk/types.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('ErrorCode'); + expect(result.diagnostics[0]!.message).toContain('split'); + }); + + it('emits warning for re-exported RequestHandlerExtra', () => { + const input = `export { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('RequestHandlerExtra'); + }); + + it('emits warning for aliased import mixing symbols from different v2 packages', () => { + const input = [ + `import { StreamableHTTPServerTransport as T, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics.some(d => d.message.includes('mixes symbols') && d.message.includes('Split'))).toBe(true); + }); + + it('emits warning for re-export mixing symbols from different v2 packages', () => { + const input = `export { StreamableHTTPServerTransport, EventStore } from '@modelcontextprotocol/sdk/server/streamableHttp.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.some(d => d.message.includes('mixes symbols') && d.message.includes('Split'))).toBe(true); + }); + + it('returns usedPackages on early return when only re-exports exist', () => { + const input = `export { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.usedPackages).toBeDefined(); + expect(result.usedPackages!.has('@modelcontextprotocol/server')).toBe(true); + }); + + it('emits warning for re-exported IsomorphicHeaders', () => { + const input = `export { IsomorphicHeaders } from '@modelcontextprotocol/sdk/types.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('IsomorphicHeaders'); + expect(result.diagnostics[0]!.message).toContain('removed'); + }); + + it('handles unknown auth subpath via catch-all', () => { + const input = `import { SomeType } from '@modelcontextprotocol/sdk/server/auth/handler.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(result.changesCount).toBe(1); + expect(sourceFile.getFullText()).not.toContain('@modelcontextprotocol/sdk'); + expect(result.diagnostics.some(d => d.message.includes('Server auth removed'))).toBe(true); + }); + + it('rewrites InMemoryTransport to @modelcontextprotocol/server for server projects', () => { + const input = `import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';\n`; + const result = applyTransform(input, { projectType: 'server' }); + expect(result).toContain(`from "@modelcontextprotocol/server"`); + expect(result).toContain('InMemoryTransport'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites InMemoryTransport to @modelcontextprotocol/client for client projects', () => { + const input = `import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';\n`; + const result = applyTransform(input, { projectType: 'client' }); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('InMemoryTransport'); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('includes subpath target in usedPackages for stdio-only file', () => { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile( + 'test.ts', + `import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';\n` + ); + const result = importPathsTransform.apply(sourceFile, { projectType: 'client' }); + expect(result.usedPackages).toBeDefined(); + expect(result.usedPackages!.has('@modelcontextprotocol/client/stdio')).toBe(true); + }); + + it('removes zod-compat import with warning', () => { + const input = `import { AnySchema, SchemaOutput } from '@modelcontextprotocol/sdk/server/zod-compat.js';\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + expect(sourceFile.getFullText()).not.toContain('AnySchema'); + expect(sourceFile.getFullText()).not.toContain('@modelcontextprotocol/sdk'); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('zod-compat'); + }); + + it('renames ResourceTemplate to ResourceTemplateType in types.js imports', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import { ResourceTemplate } from '@modelcontextprotocol/sdk/types.js';`, + `const t: ResourceTemplate = getTemplate();`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = importPathsTransform.apply(sourceFile, { projectType: 'server' }); + const output = sourceFile.getFullText(); + expect(result.changesCount).toBeGreaterThan(0); + expect(output).toContain('ResourceTemplateType'); + expect(output).not.toMatch(/(? { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'both' }); + expect(result).toContain(`from "@modelcontextprotocol/client"`); + expect(result).toContain('InMemoryTransport'); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts new file mode 100644 index 0000000..b89e4c8 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mcpServerApi.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { mcpServerApiTransform } from '../../../src/migrations/v1-to-v2/transforms/mcpServerApi.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; +const MCP_IMPORT = `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\n`; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + code); + mcpServerApiTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('mcp-server-api transform', () => { + it('converts .tool(name, callback) to .registerTool(name, {}, callback)', () => { + const input = [`server.tool('ping', async () => {`, ` return { content: [{ type: 'text', text: 'pong' }] };`, `});`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain("'ping'"); + expect(result).toContain('{}'); + }); + + it('converts .tool(name, schema, callback) wrapping raw shape', () => { + const input = [ + `server.tool('greet', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + }); + + it('converts .tool(name, description, schema, callback)', () => { + const input = [ + `server.tool('greet', 'Greet user', { name: z.string() }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain("description: 'Greet user'"); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + }); + + it('converts .tool(name, schema, annotations, callback) when args[1] is not a string', () => { + const input = [ + `server.tool('greet', { name: z.string() }, { readOnlyHint: true }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + expect(result).toContain('annotations: { readOnlyHint: true }'); + expect(result).not.toContain('description'); + }); + + it('converts .tool(name, description, schema, annotations, callback) with 5 args', () => { + const input = [ + `server.tool('greet', 'Greet user', { name: z.string() }, { readOnlyHint: true }, async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain("description: 'Greet user'"); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + expect(result).toContain('annotations: { readOnlyHint: true }'); + }); + + it('handles template expression description in .tool()', () => { + const input = [ + "server.tool('greet', `Hello ${world}`, { name: z.string() }, async ({ name }) => {", + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool'); + expect(result).toContain('description: `Hello ${world}`'); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + }); + + it('converts .prompt(name, schema, callback)', () => { + const input = [ + `server.prompt('summarize', { text: z.string() }, async ({ text }) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerPrompt'); + expect(result).toContain('argsSchema: z.object({ text: z.string() })'); + }); + + it('converts .resource(name, uri, callback) inserting empty metadata', () => { + const input = [ + `server.resource('config', 'config://app', async (uri) => {`, + ` return { contents: [{ uri: uri.href, text: '{}' }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerResource'); + expect(result).toContain('{}'); + }); + + it('applies transform when McpServer is aliased', () => { + const input = [ + `import { McpServer as Server } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `const server = new Server({ name: 'test', version: '1.0' });`, + `server.tool('ping', async () => {`, + ` return { content: [{ type: 'text', text: 'pong' }] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBeGreaterThan(0); + expect(sourceFile.getFullText()).toContain('registerTool'); + }); + + it('does not modify .tool() calls in files without MCP imports', () => { + const input = [`import { someLib } from 'other-package';`, `someLib.tool('test', async () => {});`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain("someLib.tool('test'"); + expect(sourceFile.getFullText()).not.toContain('registerTool'); + }); + + it('does not wrap z.object() schemas', () => { + const input = [ + `server.tool('greet', z.object({ name: z.string() }), async ({ name }) => {`, + ` return { content: [{ type: 'text', text: name }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('inputSchema: z.object({ name: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); + + it('converts .resource(name, uri, metadata, callback) renaming method only', () => { + const input = [ + `server.resource('config', 'config://app', { description: 'App config' }, async (uri) => {`, + ` return { contents: [{ uri: uri.href, text: '{}' }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerResource'); + expect(result).toContain("{ description: 'App config' }"); + expect(result).not.toContain('.resource('); + }); + + it('converts .prompt(name, callback) with empty config', () => { + const input = [`server.prompt('greet', async () => {`, ` return { messages: [] };`, `});`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerPrompt'); + expect(result).toContain('{}'); + expect(result).not.toContain('.prompt('); + }); + + it('converts .prompt(name, description, schema, callback)', () => { + const input = [ + `server.prompt('summarize', 'Summarize text', { text: z.string() }, async ({ text }) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerPrompt'); + expect(result).toContain("description: 'Summarize text'"); + expect(result).toContain('argsSchema: z.object({ text: z.string() })'); + }); + + it('is idempotent', () => { + const input = + MCP_IMPORT + + [`server.tool('ping', async () => {`, ` return { content: [{ type: 'text', text: 'pong' }] };`, `});`, ''].join('\n'); + const project1 = new Project({ useInMemoryFileSystem: true }); + const sf1 = project1.createSourceFile('test.ts', input); + mcpServerApiTransform.apply(sf1, ctx); + const first = sf1.getFullText(); + + const project2 = new Project({ useInMemoryFileSystem: true }); + const sf2 = project2.createSourceFile('test.ts', first); + mcpServerApiTransform.apply(sf2, ctx); + const second = sf2.getFullText(); + + expect(second).toBe(first); + }); + + it('emits warning for .resource() with 5+ arguments', () => { + const input = [`server.resource('name', 'uri://x', metadata, callback, extraArg);`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Could not automatically migrate .resource()'); + // Verify the method name was NOT mutated when migration fails + expect(sourceFile.getFullText()).toContain('.resource('); + expect(sourceFile.getFullText()).not.toContain('registerResource'); + }); + + it('wraps raw argsSchema in .registerPrompt() config', () => { + const input = [ + `server.registerPrompt("args-prompt", { argsSchema: { city: z.string(), state: z.string().optional() } }, (args) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('argsSchema: z.object({ city: z.string(), state: z.string().optional() })'); + expect(result).not.toContain('argsSchema: { city:'); + }); + + it('wraps raw inputSchema in .registerTool() config', () => { + const input = [ + `server.registerTool("echo", { inputSchema: { msg: z.string() } }, async ({ msg }) => {`, + ` return { content: [{ type: 'text', text: msg }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('inputSchema: z.object({ msg: z.string() })'); + expect(result).not.toContain('inputSchema: { msg:'); + }); + + it('does not double-wrap z.object() in .registerTool() config', () => { + const input = [ + `server.registerTool("echo", { inputSchema: z.object({ msg: z.string() }) }, async ({ msg }) => {`, + ` return { content: [{ type: 'text', text: msg }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('inputSchema: z.object({ msg: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); + + it('does not double-wrap z.object() in .registerPrompt() config', () => { + const input = [ + `server.registerPrompt("args-prompt", { argsSchema: z.object({ city: z.string() }) }, (args) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('argsSchema: z.object({ city: z.string() })'); + expect(result).not.toContain('z.object(z.object('); + }); + + it('emits diagnostic for variable-valued schema in config', () => { + const input = [ + `const promptArgsSchema = { city: z.string() };`, + `server.registerPrompt("args-prompt", { argsSchema: promptArgsSchema }, (args) => {`, + ` return { messages: [] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toContain('argsSchema: promptArgsSchema'); + expect(text).not.toContain('z.object(promptArgsSchema)'); + expect(result.diagnostics.some(d => d.message.includes('not an object literal'))).toBe(true); + }); + + it('emits diagnostic for shorthand schema property in config', () => { + const input = [ + `server.registerTool("echo", { inputSchema }, async ({ msg }) => {`, + ` return { content: [{ type: 'text', text: msg }] };`, + `});`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toContain('{ inputSchema }'); + expect(result.diagnostics.some(d => d.message.includes('Shorthand'))).toBe(true); + }); + + it('leaves .registerTool() without inputSchema unchanged', () => { + const input = [ + `server.registerTool("ping", {}, async () => {`, + ` return { content: [{ type: 'text', text: 'pong' }] };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('registerTool("ping", {}'); + expect(result).not.toContain('z.object'); + }); + + it('moves taskStore from top-level options into capabilities.tasks', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskStore, capabilities: { tasks: { list: {} } } });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).not.toMatch(/,\s*taskStore\s*,\s*capabilities/); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskStore/); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('moves taskMessageQueue from top-level options into capabilities.tasks', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskMessageQueue, capabilities: { tasks: { list: {} } } });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).not.toMatch(/,\s*taskMessageQueue\s*,\s*capabilities/); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskMessageQueue/); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('moves both taskStore and taskMessageQueue into capabilities.tasks', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskStore, taskMessageQueue, capabilities: { tasks: {} } });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskStore/); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskMessageQueue/); + }); + + it('moves both task props with complex values without corrupting output', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskStore: createStore(a, b), taskMessageQueue: new Queue({ size: 10 }), capabilities: { tasks: {} } });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskStore:\s*createStore\(a, b\)/); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskMessageQueue:\s*new Queue\(\{\s*size:\s*10\s*\}\)/); + expect(text).toContain('capabilities'); + }); + + it('emits warning when capabilities.tasks object is missing', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskStore, capabilities: {} });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const taskWarnings = result.diagnostics.filter(d => d.message.includes('taskStore')); + expect(taskWarnings.length).toBe(1); + expect(taskWarnings[0]!.message).toContain('capabilities.tasks'); + }); + + it('does not touch constructor without taskStore or taskMessageQueue', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { capabilities: {} });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const taskWarnings = result.diagnostics.filter(d => d.message.includes('taskStore') || d.message.includes('taskMessageQueue')); + expect(taskWarnings.length).toBe(0); + }); + + it('moves taskStore with complex value expression (nested braces/commas)', () => { + const input = `const server = new McpServer({ name: "test", version: "1.0" }, { taskStore: new InMemoryTaskStore({ ttl: 5000 }), capabilities: { tasks: { list: {} } } });\n`; + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', MCP_IMPORT + input); + const result = mcpServerApiTransform.apply(sourceFile, ctx); + const text = sourceFile.getFullText(); + expect(text).toMatch(/tasks:\s*\{[\s\S]*taskStore:\s*new InMemoryTaskStore\(\{\s*ttl:\s*5000\s*\}\)/); + expect(text).not.toMatch(/,\s*taskStore.*capabilities/); + expect(result.changesCount).toBeGreaterThan(0); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts new file mode 100644 index 0000000..3cb1aec --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/mockPaths.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { mockPathsTransform } from '../../../src/migrations/v1-to-v2/transforms/mockPaths.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string, context: TransformContext = ctx): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + mockPathsTransform.apply(sourceFile, context); + return sourceFile.getFullText(); +} + +describe('mock-paths transform', () => { + describe('vi.doMock', () => { + it('rewrites SDK path in vi.doMock', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, + ` McpServer: mockMcpServerClass`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('renames symbols in vi.doMock factory for streamableHttp', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: mockTransport`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/node'`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + expect(result).not.toMatch(/(? { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js', () => ({`, + ` WebStandardStreamableHTTPServerTransport: mockTransport`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + + it('rewrites sdk/types.js path', () => { + const input = [ + `vi.doMock('@modelcontextprotocol/sdk/types.js', async importOriginal => {`, + ` const original = await importOriginal();`, + ` return { ...original, isInitializeRequest: mockFn };`, + `});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + }); + + describe('vi.mock', () => { + it('rewrites SDK path in vi.mock', () => { + const input = [`vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: vi.fn()`, `}));`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + + it('rewrites client stdio mock to /stdio subpath', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({`, + ` StdioClientTransport: vi.fn()`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/client/stdio'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + + it('rewrites server stdio mock to /stdio subpath', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({`, + ` StdioServerTransport: vi.fn()`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server/stdio'`); + expect(result).not.toContain('@modelcontextprotocol/sdk'); + }); + }); + + describe('jest.mock', () => { + it('rewrites SDK path in jest.mock', () => { + const input = [`jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: jest.fn()`, `}));`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/server'`); + }); + + it('rewrites SDK path in jest.doMock', () => { + const input = [ + `jest.doMock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: jest.fn()`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/node'`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + }); + }); + + describe('dynamic imports', () => { + it('rewrites dynamic import path', () => { + const input = [ + `const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + }); + + it('preserves local binding when renaming dynamic import destructuring', () => { + const input = [ + `const { StreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + `const transport = new StreamableHTTPServerTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport: StreamableHTTPServerTransport'); + expect(result).toContain('new StreamableHTTPServerTransport({})'); + }); + + it('handles aliased dynamic import destructuring', () => { + const input = [ + `const { StreamableHTTPServerTransport: MyTransport } = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, + `const t = new MyTransport({});`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/node')`); + expect(result).toContain('NodeStreamableHTTPServerTransport: MyTransport'); + expect(result).toContain('new MyTransport({})'); + }); + + it('rewrites dynamic import for server/mcp.js', () => { + const input = [`const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('@modelcontextprotocol/server')`); + expect(result).toContain('McpServer'); + }); + + it('does not touch non-SDK dynamic imports', () => { + const input = [`const { something } = await import('some-other-package');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`import('some-other-package')`); + }); + }); + + describe('edge cases', () => { + it('skips non-SDK mock paths', () => { + const input = [`vi.doMock('some-other-package', () => ({ foo: vi.fn() }));`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('some-other-package'); + }); + + it('is idempotent', () => { + const input = [`vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({`, ` McpServer: mockClass`, `}));`, ''].join( + '\n' + ); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('emits warning for unknown SDK dynamic import path', () => { + const input = [`const m = await import('@modelcontextprotocol/sdk/unknown/path.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Unknown SDK dynamic import path'); + }); + + it('emits warning for removed SDK dynamic import path', () => { + const input = [`const m = await import('@modelcontextprotocol/sdk/server/sse.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('SSE server transport removed in v2'); + }); + + it('emits warning for unknown SDK mock path', () => { + const input = [`vi.doMock('@modelcontextprotocol/sdk/unknown/path.js', () => ({}));`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('Unknown SDK mock path'); + }); + + it('does not pick up nested properties for symbolTargetOverrides routing', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({`, + ` handleRequest: vi.fn()`, + ` }))`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`'@modelcontextprotocol/node'`); + expect(result).toContain('NodeStreamableHTTPServerTransport'); + }); + + it('does not rename nested property keys in mock factory', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, + ` McpError: vi.fn().mockImplementation(() => ({`, + ` McpError: 'nested prop should not be renamed'`, + ` }))`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolError:'); + const lines = result.split('\n'); + const nestedLine = lines.find(l => l.includes("'nested prop should not be renamed'")); + expect(nestedLine).toContain('McpError'); + }); + + it('renames SIMPLE_RENAMES symbols in mock factory', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/types.js', () => ({`, + ` McpError: vi.fn(),`, + ` ResourceReference: { type: 'resource' },`, + `}));`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('ProtocolError'); + expect(result).toContain('ResourceTemplateReference'); + expect(result).not.toMatch(/(? { + const input = [ + `const { McpError, ResourceReference } = await import('@modelcontextprotocol/sdk/types.js');`, + `const err = new McpError('fail');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toContain('ProtocolError: McpError'); + expect(result).toContain('ResourceTemplateReference: ResourceReference'); + expect(result).toContain('new McpError('); + }); + + it('emits warning for mixed-package mock factory symbols', () => { + const input = [ + `vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({`, + ` StreamableHTTPServerTransport: vi.fn(),`, + ` EventStore: vi.fn()`, + `}));`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('mixes symbols that belong to different v2 packages'); + }); + + it('does not emit warning for non-destructured dynamic import of module without renamedSymbols', () => { + const input = [`const mod = await import('@modelcontextprotocol/sdk/server/mcp.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + const renameWarnings = result.diagnostics.filter(d => d.message.includes('Symbol renames')); + expect(renameWarnings).toHaveLength(0); + }); + + it('emits warning for non-destructured dynamic import of module with renamedSymbols', () => { + const input = [`const mod = await import('@modelcontextprotocol/sdk/server/streamableHttp.js');`, ''].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = mockPathsTransform.apply(sourceFile, ctx); + const renameWarnings = result.diagnostics.filter(d => d.message.includes('Symbol renames')); + expect(renameWarnings).toHaveLength(1); + expect(renameWarnings[0]!.message).toContain('StreamableHTTPServerTransport'); + expect(renameWarnings[0]!.message).not.toContain('McpError'); + }); + + it('renames SIMPLE_RENAMES symbols in aliased dynamic import destructuring', () => { + const input = [ + `const { McpError: MyError } = await import('@modelcontextprotocol/sdk/types.js');`, + `throw new MyError('fail');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolError: MyError'); + expect(result).toContain('new MyError('); + }); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts new file mode 100644 index 0000000..3e003f4 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/removedApis.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { removedApisTransform } from '../../../src/migrations/v1-to-v2/transforms/removedApis.js'; +import type { TransformContext } from '../../../src/types.js'; +import { DiagnosticLevel } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string, context: TransformContext = ctx) { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + const result = removedApisTransform.apply(sourceFile, context); + return { text: sourceFile.getFullText(), result }; +} + +describe('removed-apis transform', () => { + describe('removed Zod helpers', () => { + it('removes schemaToJson import and emits warning', () => { + const input = [`import { schemaToJson } from '@modelcontextprotocol/server';`, `const json = schemaToJson(schema);`, ''].join( + '\n' + ); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { schemaToJson }'); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.level).toBe(DiagnosticLevel.Warning); + expect(result.diagnostics[0]!.message).toContain('schemaToJson'); + }); + + it('removes parseSchemaAsync import and emits warning', () => { + const input = [ + `import { parseSchemaAsync } from '@modelcontextprotocol/server';`, + `const result = await parseSchemaAsync(schema, data);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { parseSchemaAsync }'); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.message).toContain('parseSchemaAsync'); + }); + + it('removes getSchemaShape import and emits warning', () => { + const input = [ + `import { getSchemaShape } from '@modelcontextprotocol/server';`, + `const shape = getSchemaShape(schema);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('import { getSchemaShape }'); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.message).toContain('getSchemaShape'); + }); + + it('removes multiple zod helpers from same import declaration', () => { + const input = [`import { schemaToJson, parseSchemaAsync, getSchemaShape } from '@modelcontextprotocol/server';`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).not.toContain('schemaToJson'); + expect(text).not.toContain('parseSchemaAsync'); + expect(text).not.toContain('getSchemaShape'); + expect(text).not.toContain("from '@modelcontextprotocol/server'"); + expect(result.changesCount).toBe(3); + expect(result.diagnostics).toHaveLength(3); + }); + + it('preserves non-removed symbols in same import', () => { + const input = [ + `import { McpServer, schemaToJson } from '@modelcontextprotocol/server';`, + `const server = new McpServer({ name: 'test', version: '1.0' });`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("import { McpServer } from '@modelcontextprotocol/server'"); + expect(text).not.toContain('schemaToJson'); + expect(result.changesCount).toBe(1); + }); + + it('does not touch non-MCP imports with same names', () => { + const input = [`import { schemaToJson } from 'some-other-lib';`, `const json = schemaToJson(schema);`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("import { schemaToJson } from 'some-other-lib'"); + expect(result.changesCount).toBe(0); + }); + + it('does not remove same-named import from non-MCP package when MCP import is also present', () => { + const input = [ + `import { McpServer, schemaToJson } from '@modelcontextprotocol/server';`, + `import { schemaToJson as otherToJson } from 'some-json-schema-lib';`, + `const json = otherToJson(schema);`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain("import { schemaToJson as otherToJson } from 'some-json-schema-lib'"); + expect(text).toContain('otherToJson(schema)'); + }); + + it('is idempotent', () => { + const input = [`import { schemaToJson } from '@modelcontextprotocol/server';`, `const json = schemaToJson(schema);`, ''].join( + '\n' + ); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + }); + + describe('IsomorphicHeaders removal', () => { + it('replaces IsomorphicHeaders with Headers in type annotations', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const headers: IsomorphicHeaders = new Headers();`, + `function getHeaders(): IsomorphicHeaders { return new Headers(); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('const headers: Headers'); + expect(text).toContain('function getHeaders(): Headers'); + expect(text).not.toContain('IsomorphicHeaders'); + }); + + it('removes IsomorphicHeaders import entirely', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain("from '@modelcontextprotocol/server'"); + }); + + it('preserves other imports when removing IsomorphicHeaders', () => { + const input = [ + `import { McpServer, IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain("import { McpServer } from '@modelcontextprotocol/server'"); + expect(text).not.toContain('IsomorphicHeaders'); + }); + + it('emits warning about Headers API differences', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { result } = applyTransform(input); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.level).toBe(DiagnosticLevel.Warning); + expect(result.diagnostics[0]!.message).toContain('Headers'); + }); + + it('is idempotent', () => { + const input = [ + `import { IsomorphicHeaders } from '@modelcontextprotocol/server';`, + `const h: IsomorphicHeaders = {};`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + }); + + describe('StreamableHTTPError → SdkError', () => { + it('renames StreamableHTTPError to SdkError in references', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) { throw error; }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('instanceof SdkError'); + expect(text).not.toContain('StreamableHTTPError'); + }); + + it('adds SdkError import without SdkErrorCode when no constructor calls', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('SdkError'); + expect(text).not.toContain('SdkErrorCode'); + }); + + it('adds SdkError and SdkErrorCode imports when constructor calls exist', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `throw new StreamableHTTPError(404, 'Not Found');`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('SdkError'); + expect(text).toContain('SdkErrorCode'); + }); + + it('emits warning for constructor calls', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `throw new StreamableHTTPError(404, 'Not Found');`, + '' + ].join('\n'); + const { result } = applyTransform(input); + const constructorWarning = result.diagnostics.find(d => d.message.includes('Constructor arguments differ')); + expect(constructorWarning).toBeDefined(); + }); + + it('emits general migration warning', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { result } = applyTransform(input); + const migrationWarning = result.diagnostics.find(d => d.message.includes('error.data?.status')); + expect(migrationWarning).toBeDefined(); + }); + + it('removes old import and adds new one', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain('import { StreamableHTTPError }'); + expect(text).toMatch(/import.*SdkError/); + }); + + it('is idempotent', () => { + const input = [ + `import { StreamableHTTPError } from '@modelcontextprotocol/client';`, + `if (error instanceof StreamableHTTPError) {}`, + '' + ].join('\n'); + const { text: first } = applyTransform(input); + const { text: second } = applyTransform(first); + expect(second).toBe(first); + }); + + it('handles aliased StreamableHTTPError import', () => { + const input = [ + `import { StreamableHTTPError as SHE } from '@modelcontextprotocol/client';`, + `if (error instanceof SHE) {}`, + `throw new SHE(404, 'Not Found');`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('instanceof SdkError'); + expect(text).not.toMatch(/\bSHE\b/); + expect(text).toMatch(/import.*SdkError/); + const constructorWarning = result.diagnostics.find(d => d.message.includes('Constructor arguments differ')); + expect(constructorWarning).toBeDefined(); + }); + }); + + describe('IsomorphicHeaders alias', () => { + it('handles aliased IsomorphicHeaders import', () => { + const input = [`import { IsomorphicHeaders as IH } from '@modelcontextprotocol/server';`, `const h: IH = new IH();`, ''].join( + '\n' + ); + const { text } = applyTransform(input); + expect(text).toContain('const h: Headers = new Headers()'); + expect(text).not.toMatch(/\bIH\b/); + }); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts new file mode 100644 index 0000000..f1a2413 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/schemaParamRemoval.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { schemaParamRemovalTransform } from '../../../src/migrations/v1-to-v2/transforms/schemaParamRemoval.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'client' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + schemaParamRemovalTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('schema-param-removal transform', () => { + it('removes schema from client.request()', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call', params: {} }, CallToolResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("client.request({ method: 'tools/call', params: {} })"); + expect(result).not.toContain('CallToolResultSchema'); + }); + + it('removes schema from client.callTool()', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.callTool({ name: 'test', arguments: {} }, CallToolResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("client.callTool({ name: 'test', arguments: {} })"); + expect(result).not.toContain('CallToolResultSchema'); + }); + + it('does not remove schema from generic send() calls', () => { + const input = [ + `import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: {} }, CreateMessageResultSchema, { timeout: 5000 });`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('CreateMessageResultSchema'); + expect(result).toContain('{ timeout: 5000 }'); + }); + + it('does not remove non-schema arguments', () => { + const input = [`const result = await client.request({ method: 'tools/call' }, { timeout: 5000 });`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ timeout: 5000 }'); + }); + + it('does not remove custom schemas not imported from MCP', () => { + const input = [ + `import { MyCustomSchema } from './my-schemas';`, + `const result = await client.request(params, MyCustomSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('MyCustomSchema'); + }); + + it('is idempotent', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call' }, CallToolResultSchema);`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('does not remove schema from generic sendRequest calls', () => { + const input = [ + `import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await extra.sendRequest({ method: 'sampling/createMessage', params }, CreateMessageResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('CreateMessageResultSchema'); + expect(result).toContain('extra.sendRequest('); + }); + + it('does not remove aliased schema from generic sendRequest calls', () => { + const input = [ + `import { CreateMessageResultSchema as CMRS } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await extra.sendRequest({ method: 'sampling/createMessage', params }, CMRS);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('CMRS'); + expect(result).toContain('extra.sendRequest('); + }); + + it('counts one change per removed schema argument', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call' }, CallToolResultSchema);`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = schemaParamRemovalTransform.apply(sourceFile, { projectType: 'unknown' }); + expect(result.changesCount).toBe(1); + }); + + it('removes the import declaration when all schemas are removed', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';`, + `const result = await client.request({ method: 'tools/call' }, CallToolResultSchema);`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import.*CallToolResultSchema/); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts b/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts new file mode 100644 index 0000000..6909083 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/specSchemaAccess.test.ts @@ -0,0 +1,507 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { specSchemaAccessTransform } from '../../../src/migrations/v1-to-v2/transforms/specSchemaAccess.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string) { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + const result = specSchemaAccessTransform.apply(sourceFile, ctx); + return { text: sourceFile.getFullText(), result }; +} + +describe('spec-schema-access transform', () => { + describe('auto-transform: .safeParse(v).success → isSpecType.X(v)', () => { + it('rewrites XSchema.safeParse(v).success to isSpecType.X(v)', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, + `const valid = CallToolRequestSchema.safeParse(data).success;`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('isSpecType.CallToolRequest(data)'); + expect(text).not.toContain('safeParse'); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('handles safeParse().success in if-condition', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `if (ToolSchema.safeParse(obj).success) { doSomething(); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('isSpecType.Tool(obj)'); + expect(text).not.toContain('safeParse'); + }); + + it('adds isSpecType import when transforming safeParse().success', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const ok = CallToolResultSchema.safeParse(x).success;`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('isSpecType'); + expect(text).toMatch(/import.*isSpecType.*from/); + }); + }); + + describe('auto-transform: value position → specTypeSchemas.X', () => { + it('replaces schema passed as function arg with specTypeSchemas.X', () => { + const input = [ + `import { ListToolsRequestSchema } from '@modelcontextprotocol/server';`, + `validate(ListToolsRequestSchema);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.ListToolsRequest'); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics.length).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('StandardSchemaV1'); + }); + + it('adds specTypeSchemas import', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const s = ToolSchema;`, ''].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('specTypeSchemas.Tool'); + expect(text).toMatch(/import.*specTypeSchemas.*from/); + }); + }); + + describe('auto-transform: captured safeParse result', () => { + it('rewrites captured safeParse call and result property accesses', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (parsed.success) { return parsed.data; }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("specTypeSchemas.CallToolResult['~standard'].validate(data)"); + expect(text).toContain('parsed.issues === undefined'); + expect(text).toContain('parsed.value'); + expect(text).not.toContain('safeParse'); + expect(text).not.toContain('parsed.success'); + expect(text).not.toContain('parsed.data'); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('rewrites result properties assigned to variables (const isValid = parsed.success)', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `const isValid = parsed.success;`, + `const result = parsed.data;`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('parsed.issues === undefined'); + expect(text).toContain('parsed.value'); + expect(text).not.toContain('parsed.success'); + expect(text).not.toContain('parsed.data'); + }); + + it('rewrites .error to .issues', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const result = ToolSchema.safeParse(raw);`, + `if (!result.success) { console.log(result.error); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('result.issues'); + expect(text).not.toContain('result.error'); + }); + + it('handles ternary pattern: x.success ? x.data : fallback', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(toolResult);`, + `return parsed.success ? parsed.data : undefined;`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain("specTypeSchemas.CallToolResult['~standard'].validate(toolResult)"); + expect(text).toContain('(parsed.issues === undefined) ? parsed.value : undefined'); + }); + + it('adds specTypeSchemas import', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const r = ToolSchema.safeParse(v);`, + `r.success;`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toMatch(/import.*specTypeSchemas.*from/); + }); + + it('rewrites .error.issues to .issues (unwrap double nesting)', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (!parsed.success) { console.log(parsed.error.issues); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('parsed.issues'); + expect(text).not.toContain('parsed.issues.issues'); + expect(text).not.toContain('parsed.error'); + }); + + it('rewrites .error.message to issues map expression', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (!parsed.success) { console.log(parsed.error.message); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toContain('parsed.error'); + expect(text).not.toContain('parsed.issues.message'); + expect(text).toContain("parsed.issues?.map(i => i.message).join(', ')"); + }); + + it('emits diagnostic for .error.format() instead of silently rewriting', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/server';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (!parsed.success) { console.log(parsed.error.format()); }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('parsed.error.format()'); + expect(text).not.toContain('parsed.issues()'); + expect(result.diagnostics.some(d => d.message.includes('no StandardSchema equivalent'))).toBe(true); + }); + + it('rewrites bare .error to .issues (unchanged behavior)', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const result = ToolSchema.safeParse(raw);`, + `if (!result.success) { console.log(result.error); }`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('result.issues'); + expect(text).not.toContain('result.error'); + }); + + it('does not rewrite same-named variable in sibling function', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';`, + `function validate(d: unknown) {`, + ` const result = CallToolRequestSchema.safeParse(d);`, + ` return result.success;`, + `}`, + `async function callApi(client: any) {`, + ` const result = await client.get('/api');`, + ` return result.data;`, + `}`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('result.issues === undefined'); + expect(text).toContain('return result.data'); + expect(text).not.toContain('return result.value'); + }); + + it('falls back to diagnostic for non-captured safeParse (bare expression)', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `ToolSchema.safeParse(data);`, ''].join('\n'); + const { result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(1); + }); + }); + + describe('guardrails: non-MCP schemas are NOT touched', () => { + it('does not rewrite safeParse on user-defined schema with same name from local import', () => { + const input = [ + `import { CallToolResultSchema } from './mySchemas';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (parsed.success) { return parsed.data; }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('CallToolResultSchema.safeParse'); + expect(text).toContain('parsed.success'); + expect(text).toContain('parsed.data'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + + it('does not rewrite safeParse on user zod schema not from MCP', () => { + const input = [ + `import { z } from 'zod';`, + `const MySchema = z.object({ name: z.string() });`, + `const parsed = MySchema.safeParse(data);`, + `if (parsed.success) { return parsed.data; }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('MySchema.safeParse'); + expect(text).toContain('parsed.success'); + expect(text).toContain('parsed.data'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + + it('does not rewrite safeParse on non-spec schema name from MCP import', () => { + const input = [ + `import { SomeRandomSchema } from '@modelcontextprotocol/server';`, + `const parsed = SomeRandomSchema.safeParse(data);`, + `if (parsed.success) { return parsed.data; }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('SomeRandomSchema.safeParse'); + expect(text).toContain('parsed.success'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + + it('does not rewrite safeParse on npm package schema with matching name', () => { + const input = [ + `import { CallToolResultSchema } from 'some-other-package';`, + `const parsed = CallToolResultSchema.safeParse(data);`, + `if (parsed.success) { return parsed.data; }`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('CallToolResultSchema.safeParse'); + expect(text).toContain('parsed.success'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + }); + + describe('auto-transform: generic property access → specTypeSchemas.X', () => { + it('replaces schema identifier in .parseAsync() call', () => { + const input = [ + `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, + `const tokens = await OAuthTokensSchema.parseAsync(data);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.OAuthTokens.parseAsync(data)'); + expect(text).not.toMatch(/import\s*\{[^}]*OAuthTokensSchema[^}]*\}/); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics.length).toBeGreaterThan(0); + }); + + it('replaces schema identifier in .or() call', () => { + const input = [ + `import { ServerNotificationSchema } from '@modelcontextprotocol/server';`, + `const union = ServerNotificationSchema.or(otherSchema);`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.ServerNotification.or(otherSchema)'); + expect(text).not.toMatch(/import\s*\{[^}]*ServerNotificationSchema[^}]*\}/); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('replaces schema identifier in .extend() call', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const extended = ToolSchema.extend({ extra: z.string() });`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.Tool.extend'); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('adds specTypeSchemas import for generic property access', () => { + const input = [ + `import { OAuthTokensSchema } from '@modelcontextprotocol/server';`, + `const tokens = await OAuthTokensSchema.parseAsync(data);`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toMatch(/import.*specTypeSchemas.*from/); + }); + }); + + describe('diagnostic only: .parse(v)', () => { + it('emits diagnostic for parse usage', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const tool = ToolSchema.parse(raw);`, ''].join( + '\n' + ); + const { text, result } = applyTransform(input); + expect(text).toContain('ToolSchema.parse'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.message).toContain('isSpecType.Tool'); + }); + }); + + describe('diagnostic: z.infer', () => { + it('emits diagnostic for typeof in type position', () => { + const input = [ + `import { CallToolResultSchema } from '@modelcontextprotocol/client';`, + `type Result = typeof CallToolResultSchema;`, + '' + ].join('\n'); + const { result } = applyTransform(input); + expect(result.diagnostics.length).toBe(1); + expect(result.diagnostics[0]!.message).toContain('CallToolResult'); + }); + }); + + describe('no-op cases', () => { + it('does nothing for non-MCP imports', () => { + const input = [`import { CallToolRequestSchema } from './local';`, `CallToolRequestSchema.safeParse(data);`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('CallToolRequestSchema.safeParse'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + + it('does nothing for non-spec schema names', () => { + const input = [`import { SomeRandomSchema } from '@modelcontextprotocol/server';`, `SomeRandomSchema.parse(data);`, ''].join( + '\n' + ); + const { text, result } = applyTransform(input); + expect(text).toContain('SomeRandomSchema.parse'); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + + it('does nothing when no remaining references', () => { + const input = [`import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, ''].join('\n'); + const { result } = applyTransform(input); + expect(result.changesCount).toBe(0); + expect(result.diagnostics.length).toBe(0); + }); + }); + + describe('import cleanup after transform', () => { + it('removes original schema import after all refs are auto-transformed', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, + `const valid = CallToolRequestSchema.safeParse(data).success;`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('isSpecType.CallToolRequest(data)'); + expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); + }); + + it('keeps original schema import when some refs are diagnostic-only', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, + `const valid = CallToolRequestSchema.safeParse(data).success;`, + `const parsed = CallToolRequestSchema.parse(data);`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).toContain('isSpecType.CallToolRequest(data)'); + expect(text).toContain('CallToolRequestSchema.parse'); + expect(text).toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); + }); + + it('removes schema specifier from import that also has other symbols', () => { + const input = [ + `import { CallToolRequestSchema, McpError } from '@modelcontextprotocol/server';`, + `const valid = CallToolRequestSchema.safeParse(data).success;`, + `throw new McpError(1, 'fail');`, + '' + ].join('\n'); + const { text } = applyTransform(input); + expect(text).not.toMatch(/import\s*\{[^}]*CallToolRequestSchema[^}]*\}/); + expect(text).toContain('McpError'); + expect(text).toContain(`@modelcontextprotocol/server`); + }); + }); + + describe('parent-kind guards', () => { + it('emits diagnostic for re-exported schema (ExportSpecifier)', () => { + const input = [ + `import { CallToolRequestSchema } from '@modelcontextprotocol/server';`, + `export { CallToolRequestSchema };`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('export { CallToolRequestSchema }'); + expect(result.diagnostics.some(d => d.message.includes('Re-export'))).toBe(true); + expect(result.changesCount).toBe(0); + }); + + it('expands shorthand property assignment and removes import', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const schemas = { ToolSchema };`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("'ToolSchema': specTypeSchemas.Tool"); + expect(text).not.toMatch(/import\s*\{[^}]*ToolSchema[^}]*\}/); + expect(result.changesCount).toBeGreaterThan(0); + }); + + it('skips PropertyAssignment name-node (non-shorthand)', () => { + const input = [ + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const schemas = { ToolSchema: myValidator };`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('ToolSchema: myValidator'); + expect(result.changesCount).toBe(0); + }); + + it('skips BindingElement property-name', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const { ToolSchema: local } = obj;`, ''].join( + '\n' + ); + const { text, result } = applyTransform(input); + expect(text).toContain('ToolSchema: local'); + expect(result.changesCount).toBe(0); + }); + + it('skips PropertyAccessExpression name-node (obj.ToolSchema)', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const x = registry.ToolSchema;`, ''].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('registry.ToolSchema'); + expect(text).not.toContain('specTypeSchemas'); + expect(result.changesCount).toBe(0); + }); + + it('does not emit z.infer diagnostic for runtime typeof (TypeOfExpression)', () => { + const input = [`import { ToolSchema } from '@modelcontextprotocol/server';`, `const kind = typeof ToolSchema;`, ''].join('\n'); + const { result } = applyTransform(input); + expect(result.diagnostics.every(d => !d.message.includes('z.infer'))).toBe(true); + }); + }); + + describe('namespace imports', () => { + it('does not crash when file has namespace import from same package', () => { + const input = [ + `import * as types from '@modelcontextprotocol/server';`, + `import { ToolSchema } from '@modelcontextprotocol/server';`, + `const s = ToolSchema;`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain('specTypeSchemas.Tool'); + expect(result.changesCount).toBeGreaterThan(0); + }); + }); + + describe('aliased imports', () => { + it('handles aliased import and auto-transforms captured safeParse', () => { + const input = [ + `import { CallToolRequestSchema as CTRS } from '@modelcontextprotocol/server';`, + `const result = CTRS.safeParse(data);`, + `result.success;`, + '' + ].join('\n'); + const { text, result } = applyTransform(input); + expect(text).toContain("specTypeSchemas.CallToolRequest['~standard'].validate(data)"); + expect(text).not.toContain('CTRS.safeParse'); + expect(result.changesCount).toBeGreaterThan(0); + expect(result.diagnostics[0]!.message).toContain('specTypeSchemas.CallToolRequest'); + }); + }); +}); diff --git a/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts new file mode 100644 index 0000000..d6ef103 --- /dev/null +++ b/packages/codemod/test/v1-to-v2/transforms/symbolRenames.test.ts @@ -0,0 +1,472 @@ +import { describe, it, expect } from 'vitest'; +import { Project } from 'ts-morph'; + +import { symbolRenamesTransform } from '../../../src/migrations/v1-to-v2/transforms/symbolRenames.js'; +import type { TransformContext } from '../../../src/types.js'; + +const ctx: TransformContext = { projectType: 'server' }; + +function applyTransform(code: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', code); + symbolRenamesTransform.apply(sourceFile, ctx); + return sourceFile.getFullText(); +} + +describe('symbol-renames transform', () => { + it('renames McpError to ProtocolError', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'error');`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolError'); + expect(result).not.toContain('McpError'); + }); + + it('renames JSONRPCError to JSONRPCErrorResponse', () => { + const input = [`import { JSONRPCError } from '@modelcontextprotocol/sdk/types.js';`, `const e: JSONRPCError = error;`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('JSONRPCErrorResponse'); + expect(result).not.toMatch(/\bJSONRPCError\b/); + }); + + it('renames isJSONRPCError to isJSONRPCErrorResponse', () => { + const input = [`import { isJSONRPCError } from '@modelcontextprotocol/sdk/types.js';`, `if (isJSONRPCError(x)) {}`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('isJSONRPCErrorResponse'); + }); + + it('renames isJSONRPCResponse to isJSONRPCResultResponse', () => { + const input = [`import { isJSONRPCResponse } from '@modelcontextprotocol/sdk/types.js';`, `if (isJSONRPCResponse(x)) {}`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('isJSONRPCResultResponse'); + }); + + it('renames ResourceReference to ResourceTemplateReference', () => { + const input = [ + `import { ResourceReference } from '@modelcontextprotocol/sdk/types.js';`, + `const ref: ResourceReference = { type: 'ref', uri: '' };`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ResourceTemplateReference'); + expect(result).not.toMatch(/\bResourceReference\b/); + }); + + it('splits ErrorCode into ProtocolErrorCode and SdkErrorCode', () => { + const input = [ + `import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `const a = ErrorCode.InvalidParams;`, + `const b = ErrorCode.RequestTimeout;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolErrorCode.InvalidParams'); + expect(result).toContain('SdkErrorCode.RequestTimeout'); + expect(result).not.toMatch(/\bErrorCode\./); + expect(result).not.toMatch(/import.*\bErrorCode\b/); + }); + + it('handles ErrorCode with only SDK members', () => { + const input = [`import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, `const a = ErrorCode.ConnectionClosed;`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('SdkErrorCode.ConnectionClosed'); + expect(result).toContain('SdkErrorCode'); + expect(result).not.toContain('ProtocolErrorCode'); + }); + + it('does not rename property keys that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const config = { McpError: 'some value' };`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain("{ McpError: 'some value' }"); + expect(result).toContain('new ProtocolError'); + }); + + it('does not rename property access names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const x = config.McpError;`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('config.McpError'); + expect(result).toContain('new ProtocolError'); + }); + + it('is idempotent', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `throw new McpError(1, 'error');`, ''].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('renames RequestHandlerExtra to ServerContext with server generic args', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toContain('RequestHandlerExtra'); + expect(result).not.toContain('ServerRequest'); + expect(result).not.toContain('ServerNotification'); + }); + + it('renames RequestHandlerExtra to ClientContext with client generic args', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + symbolRenamesTransform.apply(sourceFile, { projectType: 'client' }); + const result = sourceFile.getFullText(); + expect(result).toContain('ClientContext'); + expect(result).not.toContain('RequestHandlerExtra'); + }); + + it('strips generic type arguments from RequestHandlerExtra', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `const extra = {} as RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('as ServerContext;'); + expect(result).not.toContain(' { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toContain('RequestHandlerExtra'); + }); + + it('defaults RequestHandlerExtra to ClientContext for client projects', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + symbolRenamesTransform.apply(sourceFile, { projectType: 'client' }); + const result = sourceFile.getFullText(); + expect(result).toContain('ClientContext'); + }); + + it('replaces SchemaInput with StandardSchemaWithJSON.InferInput', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toContain('SchemaInput'); + }); + + it('replaces bare SchemaInput with StandardSchemaWithJSON.InferInput', () => { + const input = [`import type { SchemaInput } from '@modelcontextprotocol/server';`, `type Input = SchemaInput;`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toContain('SchemaInput'); + }); + + it('adds StandardSchemaWithJSON type import for SchemaInput migration', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON'); + expect(result).toMatch(/import type.*StandardSchemaWithJSON/); + }); + + it('removes SchemaInput import after migration', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import.*SchemaInput/); + }); + + it('imports both ServerContext and ClientContext when file has both generic arg types', () => { + const input = [ + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type S = RequestHandlerExtra;`, + `type C = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('type S = ServerContext;'); + expect(result).toContain('type C = ClientContext;'); + expect(result).toMatch(/import.*ServerContext/); + expect(result).toMatch(/import.*ClientContext/); + expect(result).not.toContain('RequestHandlerExtra'); + }); + + it('removes dead ServerRequest/ServerNotification imports after RequestHandlerExtra rename', () => { + const input = [ + `import type { RequestHandlerExtra, ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toContain('RequestHandlerExtra'); + expect(result).not.toMatch(/import.*ServerRequest/); + expect(result).not.toMatch(/import.*ServerNotification/); + }); + + it('removes dead ClientRequest/ClientNotification imports after RequestHandlerExtra rename', () => { + const input = [ + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import type { RequestHandlerExtra, ClientRequest, ClientNotification } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + symbolRenamesTransform.apply(sourceFile, { projectType: 'client' }); + const result = sourceFile.getFullText(); + expect(result).toContain('ClientContext'); + expect(result).not.toMatch(/import.*ClientRequest/); + expect(result).not.toMatch(/import.*ClientNotification/); + }); + + it('preserves generic arg imports that are still referenced elsewhere', () => { + const input = [ + `import type { RequestHandlerExtra, ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type MyHandler = (args: any, extra: RequestHandlerExtra) => void;`, + `type Req = ServerRequest;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toMatch(/import.*ServerNotification/); + expect(result).toMatch(/import.*ServerRequest/); + }); + + it('does not rename symbols from non-MCP imports', () => { + const input = [ + `import { ErrorCode } from '@grpc/grpc-js';`, + `import { ResourceReference } from '@google-cloud/asset';`, + `if (err.code === ErrorCode.NOT_FOUND) {}`, + `const ref: ResourceReference = {};`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ErrorCode.NOT_FOUND'); + expect(result).toContain('ResourceReference'); + expect(result).not.toContain('ProtocolErrorCode'); + expect(result).not.toContain('SdkErrorCode'); + expect(result).not.toContain('ResourceTemplateReference'); + }); + + it('does not split ErrorCode from non-MCP imports', () => { + const input = [ + `import { ErrorCode } from '@grpc/grpc-js';`, + `const a = ErrorCode.NOT_FOUND;`, + `const b = ErrorCode.CANCELLED;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = symbolRenamesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('ErrorCode.NOT_FOUND'); + expect(sourceFile.getFullText()).toContain('ErrorCode.CANCELLED'); + }); + + it('does not rename RequestHandlerExtra from non-MCP imports', () => { + const input = [ + `import type { RequestHandlerExtra } from './my-local-types.js';`, + `type MyHandler = (extra: RequestHandlerExtra) => void;`, + '' + ].join('\n'); + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile('test.ts', input); + const result = symbolRenamesTransform.apply(sourceFile, ctx); + expect(result.changesCount).toBe(0); + expect(sourceFile.getFullText()).toContain('RequestHandlerExtra'); + expect(sourceFile.getFullText()).not.toContain('ServerContext'); + }); + + it('cleans up empty import declaration after ErrorCode split', () => { + const input = [`import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, `const a = ErrorCode.InvalidParams;`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).not.toMatch(/import\s*\{\s*\}/); + }); + + it('cleans up empty import declaration after RequestHandlerExtra removal', () => { + const input = [ + `import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';`, + `type Extra = RequestHandlerExtra;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).not.toMatch(/import\s+type\s*\{\s*\}/); + expect(result).not.toContain('@modelcontextprotocol/sdk/shared/protocol.js'); + }); + + it('preserves shorthand property keys when renaming', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const errors = { McpError };`, + `throw new McpError(1, 'error');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('McpError: ProtocolError'); + expect(result).toContain('new ProtocolError'); + }); + + it('preserves export specifier public name with alias', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export { McpError };`, ''].join('\n'); + const result = applyTransform(input); + expect(result).toContain('export { ProtocolError as McpError }'); + }); + + it('is idempotent for SchemaInput transform', () => { + const input = [ + `import type { SchemaInput } from '@modelcontextprotocol/server';`, + `type Input = SchemaInput;`, + '' + ].join('\n'); + const first = applyTransform(input); + const second = applyTransform(first); + expect(second).toBe(first); + }); + + it('handles aliased ErrorCode import', () => { + const input = [ + `import { ErrorCode as EC } from '@modelcontextprotocol/server';`, + `const a = EC.InvalidParams;`, + `const b = EC.RequestTimeout;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ProtocolErrorCode.InvalidParams'); + expect(result).toContain('SdkErrorCode.RequestTimeout'); + expect(result).not.toMatch(/\bEC\./); + }); + + it('handles aliased RequestHandlerExtra import', () => { + const input = [ + `import type { RequestHandlerExtra as RHE } from '@modelcontextprotocol/server';`, + `type MyHandler = (extra: RHE) => void;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('ServerContext'); + expect(result).not.toMatch(/\bRHE\b/); + }); + + it('handles aliased SchemaInput import', () => { + const input = [ + `import type { SchemaInput as SI } from '@modelcontextprotocol/server';`, + `type Input = SI;`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('StandardSchemaWithJSON.InferInput'); + expect(result).not.toMatch(/\bSI\b/); + }); + + it('does not rename method signature names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `interface ErrorHandler { McpError(): void; }`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('interface ErrorHandler { McpError(): void; }'); + expect(result).toContain('new ProtocolError('); + }); + + it('does not rename enum member names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `enum Errors { McpError = 1 }`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('enum Errors { McpError = 1 }'); + expect(result).toContain('new ProtocolError('); + }); + + it('does not rename destructuring property names that match renamed symbols', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/server';`, + `const { McpError: localErr } = someObject;`, + `throw new McpError('test');`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('{ McpError: localErr }'); + expect(result).toContain('new ProtocolError('); + }); + + it('does not corrupt export specifier alias when renaming', () => { + const input = [`import { McpError } from '@modelcontextprotocol/sdk/types.js';`, `export { McpError as MyCustomError };`, ''].join( + '\n' + ); + const result = applyTransform(input); + expect(result).toContain('export { ProtocolError as MyCustomError }'); + expect(result).not.toContain('export { ProtocolError as ProtocolError }'); + }); + + it('prefers non-type-only import when choosing ErrorCode split target module', () => { + const input = [ + `import type { ServerContext } from '@modelcontextprotocol/server';`, + `import { Client } from '@modelcontextprotocol/client';`, + `import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';`, + `if (err.code === ErrorCode.InvalidParams) {}`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain(`from '@modelcontextprotocol/client'`); + expect(result).toContain('ProtocolErrorCode'); + const clientImportLine = result.split('\n').find(l => l.includes('@modelcontextprotocol/client') && !l.includes('import type')); + expect(clientImportLine).toContain('ProtocolErrorCode'); + }); + + it('does not overwrite local binding in aliased export specifier', () => { + const input = [ + `import { McpError } from '@modelcontextprotocol/sdk/types.js';`, + `const Foo = McpError;`, + `export { Foo as McpError };`, + '' + ].join('\n'); + const result = applyTransform(input); + expect(result).toContain('const Foo = ProtocolError'); + expect(result).toContain('export { Foo as McpError }'); + }); +}); diff --git a/packages/codemod/tsconfig.json b/packages/codemod/tsconfig.json new file mode 100644 index 0000000..5fc6c7e --- /dev/null +++ b/packages/codemod/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist", "test/**/fixtures", "batch-test/repos", "batch-test/results"], + "compilerOptions": { + "paths": { + "*": ["./*"] + } + } +} diff --git a/packages/codemod/tsdown.config.ts b/packages/codemod/tsdown.config.ts new file mode 100644 index 0000000..21ae185 --- /dev/null +++ b/packages/codemod/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/cli.ts', 'src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc' + } +}); diff --git a/packages/codemod/typedoc.json b/packages/codemod/typedoc.json new file mode 100644 index 0000000..a9fd090 --- /dev/null +++ b/packages/codemod/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts", "**/__*__/**"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/codemod/vitest.config.js b/packages/codemod/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/codemod/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..e25cd09 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,106 @@ +# @modelcontextprotocol/core + +## 2.0.0-alpha.1 + +### Minor Changes + +- [#1673](https://github.com/modelcontextprotocol/typescript-sdk/pull/1673) [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - refactor: extract task + orchestration from Protocol into TaskManager + + **Breaking changes:** + - `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` + +- [#1389](https://github.com/modelcontextprotocol/typescript-sdk/pull/1389) [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51) Thanks [@DePasqualeOrg](https://github.com/DePasqualeOrg)! - Fix error handling for + unknown tools and resources per MCP spec. + + **Tools:** Unknown or disabled tool calls now return JSON-RPC protocol errors with code `-32602` (InvalidParams) instead of `CallToolResult` with `isError: true`. Callers who checked `result.isError` for unknown tools should catch rejected promises instead. + + **Resources:** Unknown resource reads now return error code `-32002` (ResourceNotFound) instead of `-32602` (InvalidParams). + + Added `ProtocolErrorCode.ResourceNotFound`. + +- [#1689](https://github.com/modelcontextprotocol/typescript-sdk/pull/1689) [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Support Standard Schema + for tool and prompt schemas + + Tool and prompt registration now accepts any schema library that implements the [Standard Schema spec](https://standardschema.dev/): Zod v4, Valibot, ArkType, and others. `RegisteredTool.inputSchema`, `RegisteredTool.outputSchema`, and `RegisteredPrompt.argsSchema` now use + `StandardSchemaWithJSON` (requires both `~standard.validate` and `~standard.jsonSchema`) instead of the Zod-specific `AnySchema` type. + + **Zod v4 schemas continue to work unchanged** — Zod v4 implements the required interfaces natively. + + ```typescript + import { type } from 'arktype'; + + server.registerTool( + 'greet', + { + inputSchema: type({ name: 'string' }) + }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + ``` + + For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter: + + ```typescript + import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core'; + + server.registerTool( + 'greet', + { + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator()) + }, + handler + ); + ``` + + **Breaking changes:** + - `experimental.tasks.getTaskResult()` no longer accepts a `resultSchema` parameter. Returns `GetTaskPayloadResult` (a loose `Result`); cast to the expected type at the call site. + - Removed unused exports from `@modelcontextprotocol/core`: `SchemaInput`, `schemaToJson`, `parseSchemaAsync`, `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema`. Use the new `standardSchemaToJsonSchema` and `validateStandardSchema` instead. + - `completable()` remains Zod-specific (it relies on Zod's `.shape` introspection). + +### Patch Changes + +- [#1735](https://github.com/modelcontextprotocol/typescript-sdk/pull/1735) [`a2e5037`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a2e503733f6f3eea3a79a80bdc1b3cdd743f8bb3) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Abort in-flight request + handlers when the connection closes. Previously, request handlers would continue running after the transport disconnected, wasting resources and preventing proper cleanup. Also fixes `InMemoryTransport.close()` firing `onclose` twice on the initiating side. + +- [#1574](https://github.com/modelcontextprotocol/typescript-sdk/pull/1574) [`379392d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/379392d04460ee2cbeecae374901fae21e525031) Thanks [@olaservo](https://github.com/olaservo)! - Add missing `size` field to + `ResourceSchema` to match the MCP specification + +- [#1363](https://github.com/modelcontextprotocol/typescript-sdk/pull/1363) [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4) Thanks [@DevJanderson](https://github.com/DevJanderson)! - Fix ReDoS vulnerability in + UriTemplate regex patterns (CVE-2026-0621) + +- [#1761](https://github.com/modelcontextprotocol/typescript-sdk/pull/1761) [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Convert remaining + capability-assertion throws to `SdkError(SdkErrorCode.CapabilityNotSupported, ...)`. Follow-up to #1454 which missed `Client.assertCapability()`, the task capability helpers in `experimental/tasks/helpers.ts`, and the sampling/elicitation capability checks in + `experimental/tasks/server.ts`. + +- [#1790](https://github.com/modelcontextprotocol/typescript-sdk/pull/1790) [`89fb094`](https://github.com/modelcontextprotocol/typescript-sdk/commit/89fb0947b487b37f9bfcc2a2486dcd33d3922f8e) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Consolidate per-request + cleanup in `_requestWithSchema` into a single `.finally()` block. This fixes an abort signal listener leak (listeners accumulated when a caller reused one `AbortSignal` across requests) and two cases where `_responseHandlers` entries leaked on send-failure paths. + +- [#1486](https://github.com/modelcontextprotocol/typescript-sdk/pull/1486) [`65bbcea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/65bbceab773277f056a9d3e385e7e7d8cef54f9b) Thanks [@localden](https://github.com/localden)! - Fix InMemoryTaskStore to enforce + session isolation. Previously, sessionId was accepted but ignored on all TaskStore methods, allowing any session to enumerate, read, and mutate tasks created by other sessions. The store now persists sessionId at creation time and enforces ownership on all reads and writes. + +- [#1766](https://github.com/modelcontextprotocol/typescript-sdk/pull/1766) [`48aba0d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/48aba0d3c3b2ee04c442934095b663d19e07a3b3) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add explicit + `| undefined` to optional properties on the `Transport` interface and `TransportSendOptions` (`onclose`, `onerror`, `onmessage`, `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, `onresumptiontoken`). + + This fixes TS2420 errors for consumers using `exactOptionalPropertyTypes: true` without `skipLibCheck`, where the emitted `.d.ts` for implementing classes included `| undefined` but the interface did not. + + Workaround for older SDK versions: enable `skipLibCheck: true` in your tsconfig. + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - remove deprecated .tool, + .prompt, .resource method signatures + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- [#1796](https://github.com/modelcontextprotocol/typescript-sdk/pull/1796) [`d6a02c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/d6a02c85c0514658c27615398a3003aadce80fb0) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Ensure + `standardSchemaToJsonSchema` emits `type: "object"` at the root, fixing discriminated-union tool/prompt schemas that previously produced `{oneOf: [...]}` without the MCP-required top-level type. Also throws a clear error when given an explicitly non-object schema (e.g. + `z.string()`). Fixes #1643. + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - deprecated .tool, .prompt, + .resource method removal + +- [#1762](https://github.com/modelcontextprotocol/typescript-sdk/pull/1762) [`64897f7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/64897f78ce78f736b027dfecd1b4326c8c6678c7) Thanks [@felixweinberger](https://github.com/felixweinberger)! - + `ReadBuffer.readMessage()` now silently skips non-JSON lines instead of throwing `SyntaxError`. This prevents noisy `onerror` callbacks when hot-reload tools (tsx, nodemon) write debug output like "Gracefully restarting..." to stdout. Lines that parse as JSON but fail JSONRPC + schema validation still throw. diff --git a/packages/core/eslint.config.mjs b/packages/core/eslint.config.mjs new file mode 100644 index 0000000..951c9f3 --- /dev/null +++ b/packages/core/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..2017737 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,91 @@ +{ + "name": "@modelcontextprotocol/core", + "private": true, + "version": "2.0.0-alpha.1", + "description": "Model Context Protocol implementation for TypeScript - Core package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "core" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs" + }, + "./types": { + "types": "./src/exports/types/index.ts", + "import": "./src/exports/types/index.ts" + }, + "./public": { + "types": "./src/exports/public/index.ts", + "import": "./src/exports/public/index.ts" + }, + "./validators/cfWorker": { + "types": "./src/validators/cfWorkerProvider.ts", + "import": "./src/validators/cfWorkerProvider.ts" + } + }, + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "ajv": "catalog:runtimeShared", + "ajv-formats": "catalog:runtimeShared", + "json-schema-typed": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "zod": "catalog:runtimeShared" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@types/content-type": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/cross-spawn": "catalog:devTools", + "@types/eventsource": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts new file mode 100644 index 0000000..30c8741 --- /dev/null +++ b/packages/core/src/auth/errors.ts @@ -0,0 +1,132 @@ +import type { OAuthErrorResponse } from '../shared/auth.js'; + +/** + * OAuth error codes as defined by {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 | RFC 6749} + * and extensions. + */ +export enum OAuthErrorCode { + /** + * The request is missing a required parameter, includes an invalid parameter value, + * includes a parameter more than once, or is otherwise malformed. + */ + InvalidRequest = 'invalid_request', + + /** + * Client authentication failed (e.g., unknown client, no client authentication included, + * or unsupported authentication method). + */ + InvalidClient = 'invalid_client', + + /** + * The provided authorization grant or refresh token is invalid, expired, revoked, + * does not match the redirection URI used in the authorization request, or was issued to another client. + */ + InvalidGrant = 'invalid_grant', + + /** + * The authenticated client is not authorized to use this authorization grant type. + */ + UnauthorizedClient = 'unauthorized_client', + + /** + * The authorization grant type is not supported by the authorization server. + */ + UnsupportedGrantType = 'unsupported_grant_type', + + /** + * The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner. + */ + InvalidScope = 'invalid_scope', + + /** + * The resource owner or authorization server denied the request. + */ + AccessDenied = 'access_denied', + + /** + * The authorization server encountered an unexpected condition that prevented it from fulfilling the request. + */ + ServerError = 'server_error', + + /** + * The authorization server is currently unable to handle the request due to temporary overloading or maintenance. + */ + TemporarilyUnavailable = 'temporarily_unavailable', + + /** + * The authorization server does not support obtaining an authorization code using this method. + */ + UnsupportedResponseType = 'unsupported_response_type', + + /** + * The authorization server does not support the requested token type. + */ + UnsupportedTokenType = 'unsupported_token_type', + + /** + * The access token provided is expired, revoked, malformed, or invalid for other reasons. + */ + InvalidToken = 'invalid_token', + + /** + * The HTTP method used is not allowed for this endpoint. (Custom, non-standard error) + */ + MethodNotAllowed = 'method_not_allowed', + + /** + * Rate limit exceeded. (Custom, non-standard error based on RFC 6585) + */ + TooManyRequests = 'too_many_requests', + + /** + * The client metadata is invalid. (Custom error for dynamic client registration - RFC 7591) + */ + InvalidClientMetadata = 'invalid_client_metadata', + + /** + * The request requires higher privileges than provided by the access token. + */ + InsufficientScope = 'insufficient_scope', + + /** + * The requested resource is invalid, missing, unknown, or malformed. (Custom error for resource indicators - RFC 8707) + */ + InvalidTarget = 'invalid_target' +} + +/** + * OAuth error class for all OAuth-related errors. + */ +export class OAuthError extends Error { + constructor( + public readonly code: OAuthErrorCode | string, + message: string, + public readonly errorUri?: string + ) { + super(message); + this.name = 'OAuthError'; + } + + /** + * Converts the error to a standard OAuth error response object. + */ + toResponseObject(): OAuthErrorResponse { + const response: OAuthErrorResponse = { + error: this.code, + error_description: this.message + }; + + if (this.errorUri) { + response.error_uri = this.errorUri; + } + + return response; + } + + /** + * Creates an {@linkcode OAuthError} from an OAuth error response. + */ + static fromResponse(response: OAuthErrorResponse): OAuthError { + return new OAuthError(response.error as OAuthErrorCode, response.error_description ?? response.error, response.error_uri); + } +} diff --git a/packages/core/src/errors/sdkErrors.examples.ts b/packages/core/src/errors/sdkErrors.examples.ts new file mode 100644 index 0000000..d80fd6e --- /dev/null +++ b/packages/core/src/errors/sdkErrors.examples.ts @@ -0,0 +1,39 @@ +/** + * Type-checked examples for `sdkErrors.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { SdkError, SdkErrorCode, SdkHttpError } from './sdkErrors.js'; + +/** + * Example: Throwing and catching SDK errors. + */ +function SdkError_basicUsage() { + //#region SdkError_basicUsage + try { + // Throwing an SDK error + throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); + } catch (error) { + // Checking error type by code + if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + // Handle timeout + } + } + //#endregion SdkError_basicUsage +} + +/** + * Example: Checking for HTTP transport errors. + */ +function SdkHttpError_basicUsage(error: unknown) { + //#region SdkHttpError_basicUsage + if (error instanceof SdkHttpError) { + console.log(error.status); // number + console.log(error.statusText); // string | undefined + } + //#endregion SdkHttpError_basicUsage +} diff --git a/packages/core/src/errors/sdkErrors.ts b/packages/core/src/errors/sdkErrors.ts new file mode 100644 index 0000000..af432c6 --- /dev/null +++ b/packages/core/src/errors/sdkErrors.ts @@ -0,0 +1,110 @@ +/** + * Error codes for SDK errors (local errors that never cross the wire). + * Unlike {@linkcode ProtocolErrorCode} which uses numeric JSON-RPC codes, `SdkErrorCode` uses + * descriptive string values for better developer experience. + * + * These errors are thrown locally by the SDK and are never serialized as + * JSON-RPC error responses. + */ +export enum SdkErrorCode { + // State errors + /** Transport is not connected */ + NotConnected = 'NOT_CONNECTED', + /** Transport is already connected */ + AlreadyConnected = 'ALREADY_CONNECTED', + /** Protocol is not initialized */ + NotInitialized = 'NOT_INITIALIZED', + + // Capability errors + /** Required capability is not supported by the remote side */ + CapabilityNotSupported = 'CAPABILITY_NOT_SUPPORTED', + + // Transport errors + /** Request timed out waiting for response */ + RequestTimeout = 'REQUEST_TIMEOUT', + /** Connection was closed */ + ConnectionClosed = 'CONNECTION_CLOSED', + /** Failed to send message */ + SendFailed = 'SEND_FAILED', + /** Response result failed local schema validation */ + InvalidResult = 'INVALID_RESULT', + + // Transport errors + ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED', + ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION', + ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN', + ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT', + ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', + ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' +} + +/** + * SDK errors are local errors that never cross the wire. + * They are distinct from {@linkcode ProtocolError} which represents JSON-RPC protocol errors + * that are serialized and sent as error responses. + * + * @example + * ```ts source="./sdkErrors.examples.ts#SdkError_basicUsage" + * try { + * // Throwing an SDK error + * throw new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); + * } catch (error) { + * // Checking error type by code + * if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { + * // Handle timeout + * } + * } + * ``` + */ +export class SdkError extends Error { + constructor( + public readonly code: SdkErrorCode, + message: string, + public readonly data?: unknown + ) { + super(message); + this.name = 'SdkError'; + } +} + +/** + * Typed shape for HTTP error data carried by {@linkcode SdkHttpError}. + */ +export interface SdkHttpErrorData { + status: number; + statusText?: string; + [key: string]: unknown; +} + +/** + * An {@linkcode SdkError} subclass for HTTP transport failures. + * + * Thrown by the streamable HTTP transport when the server responds with a + * non-OK status code. Narrows {@linkcode SdkError.data | data} to + * {@linkcode SdkHttpErrorData} so consumers can inspect the HTTP status + * without unsafe casting. + * + * @example + * ```ts source="./sdkErrors.examples.ts#SdkHttpError_basicUsage" + * if (error instanceof SdkHttpError) { + * console.log(error.status); // number + * console.log(error.statusText); // string | undefined + * } + * ``` + */ +export class SdkHttpError extends SdkError { + declare readonly data: SdkHttpErrorData; + + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) { + super(code, message, data); + this.name = 'SdkHttpError'; + } + + get status(): number { + return this.data.status; + } + + get statusText(): string | undefined { + return this.data.statusText; + } +} diff --git a/packages/core/src/experimental/index.ts b/packages/core/src/experimental/index.ts new file mode 100644 index 0000000..ea39eb7 --- /dev/null +++ b/packages/core/src/experimental/index.ts @@ -0,0 +1,3 @@ +export * from './tasks/helpers.js'; +export * from './tasks/interfaces.js'; +export * from './tasks/stores/inMemory.js'; diff --git a/packages/core/src/experimental/tasks/helpers.ts b/packages/core/src/experimental/tasks/helpers.ts new file mode 100644 index 0000000..7a13fff --- /dev/null +++ b/packages/core/src/experimental/tasks/helpers.ts @@ -0,0 +1,104 @@ +/** + * Experimental task capability assertion helpers. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { SdkError, SdkErrorCode } from '../../errors/sdkErrors.js'; + +/** + * Type representing the task requests capability structure. + * This is derived from `ClientTasksCapability.requests` and `ServerTasksCapability.requests`. + */ +interface TaskRequestsCapability { + tools?: { call?: object }; + sampling?: { createMessage?: object }; + elicitation?: { create?: object }; +} + +/** + * Asserts that task creation is supported for `tools/call`. + * Used to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol. + * + * @param requests - The task requests capability object + * @param method - The method being checked + * @param entityName - `'Server'` or `'Client'` for error messages + * @throws {@linkcode SdkError} with {@linkcode SdkErrorCode.CapabilityNotSupported} if the capability is not supported + * + * @experimental + */ +export function assertToolsCallTaskCapability( + requests: TaskRequestsCapability | undefined, + method: string, + entityName: 'Server' | 'Client' +): void { + if (!requests) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `${entityName} does not support task creation (required for ${method})`); + } + + switch (method) { + case 'tools/call': { + if (!requests.tools?.call) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `${entityName} does not support task creation for tools/call (required for ${method})` + ); + } + break; + } + + default: { + // Method doesn't support tasks, which is fine - no error + break; + } + } +} + +/** + * Asserts that task creation is supported for `sampling/createMessage` or `elicitation/create`. + * Used to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol. + * + * @param requests - The task requests capability object + * @param method - The method being checked + * @param entityName - `'Server'` or `'Client'` for error messages + * @throws {@linkcode SdkError} with {@linkcode SdkErrorCode.CapabilityNotSupported} if the capability is not supported + * + * @experimental + */ +export function assertClientRequestTaskCapability( + requests: TaskRequestsCapability | undefined, + method: string, + entityName: 'Server' | 'Client' +): void { + if (!requests) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `${entityName} does not support task creation (required for ${method})`); + } + + switch (method) { + case 'sampling/createMessage': { + if (!requests.sampling?.createMessage) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `${entityName} does not support task creation for sampling/createMessage (required for ${method})` + ); + } + break; + } + + case 'elicitation/create': { + if (!requests.elicitation?.create) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `${entityName} does not support task creation for elicitation/create (required for ${method})` + ); + } + break; + } + + default: { + // Method doesn't support tasks, which is fine - no error + break; + } + } +} diff --git a/packages/core/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts new file mode 100644 index 0000000..d980f30 --- /dev/null +++ b/packages/core/src/experimental/tasks/interfaces.ts @@ -0,0 +1,243 @@ +/** + * Experimental task interfaces for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + */ + +import type { ServerContext } from '../../shared/protocol.js'; +import type { RequestTaskStore } from '../../shared/taskManager.js'; +import type { + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResultResponse, + Request, + RequestId, + Result, + Task, + ToolExecution +} from '../../types/index.js'; + +// ============================================================================ +// Task Handler Types (for registerToolTask) +// ============================================================================ + +/** + * Server context with guaranteed task store for task creation. + * @experimental + */ +export type CreateTaskServerContext = ServerContext & { + task: { store: RequestTaskStore; requestedTtl?: number }; +}; + +/** + * Server context with guaranteed task ID and store for task operations. + * @experimental + */ +export type TaskServerContext = ServerContext & { + task: { id: string; store: RequestTaskStore; requestedTtl?: number }; +}; + +/** + * Task-specific execution configuration. + * `taskSupport` cannot be `'forbidden'` for task-based tools. + * @experimental + */ +export type TaskToolExecution = Omit & { + taskSupport: TaskSupport extends 'forbidden' | undefined ? never : TaskSupport; +}; + +/** + * Represents a message queued for side-channel delivery via tasks/result. + * + * This is a serializable data structure that can be stored in external systems. + * All fields are JSON-serializable. + */ +export type QueuedMessage = QueuedRequest | QueuedNotification | QueuedResponse | QueuedError; + +export interface BaseQueuedMessage { + /** Type of message */ + type: string; + /** When the message was queued (milliseconds since epoch) */ + timestamp: number; +} + +export interface QueuedRequest extends BaseQueuedMessage { + type: 'request'; + /** The actual JSONRPC request */ + message: JSONRPCRequest; +} + +export interface QueuedNotification extends BaseQueuedMessage { + type: 'notification'; + /** The actual JSONRPC notification */ + message: JSONRPCNotification; +} + +export interface QueuedResponse extends BaseQueuedMessage { + type: 'response'; + /** The actual JSONRPC response */ + message: JSONRPCResultResponse; +} + +export interface QueuedError extends BaseQueuedMessage { + type: 'error'; + /** The actual JSONRPC error */ + message: JSONRPCErrorResponse; +} + +/** + * Interface for managing per-task FIFO message queues. + * + * Similar to {@linkcode TaskStore}, this allows pluggable queue implementations + * (in-memory, Redis, other distributed queues, etc.). + * + * Each method accepts taskId and optional sessionId parameters to enable + * a single queue instance to manage messages for multiple tasks, with + * isolation based on task ID and session ID. + * + * All methods are async to support external storage implementations. + * All data in {@linkcode QueuedMessage} must be JSON-serializable. + * + * @see {@linkcode InMemoryTaskMessageQueue} for a reference implementation + * @experimental + */ +export interface TaskMessageQueue { + /** + * Adds a message to the end of the queue for a specific task. + * Atomically checks queue size and throws if maxSize would be exceeded. + * @param taskId The task identifier + * @param message The message to enqueue + * @param sessionId Optional session ID for binding the operation to a specific session + * @param maxSize Optional maximum queue size - if specified and queue is full, throws an error + * @throws Error if maxSize is specified and would be exceeded + */ + enqueue(taskId: string, message: QueuedMessage, sessionId?: string, maxSize?: number): Promise; + + /** + * Removes and returns the first message from the queue for a specific task. + * @param taskId The task identifier + * @param sessionId Optional session ID for binding the query to a specific session + * @returns The first message, or `undefined` if the queue is empty + */ + dequeue(taskId: string, sessionId?: string): Promise; + + /** + * Removes and returns all messages from the queue for a specific task. + * Used when tasks are cancelled or failed to clean up pending messages. + * @param taskId The task identifier + * @param sessionId Optional session ID for binding the query to a specific session + * @returns Array of all messages that were in the queue + */ + dequeueAll(taskId: string, sessionId?: string): Promise; +} + +/** + * Task creation options. + * @experimental + */ +export interface CreateTaskOptions { + /** + * Duration in milliseconds to retain task from creation. + * If `null`, the task has unlimited lifetime until manually cleaned up. + */ + ttl?: number | null; + + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval?: number; + + /** + * Additional context to pass to the task store. + */ + context?: Record; +} + +/** + * Interface for storing and retrieving task state and results. + * + * Similar to {@linkcode Transport}, this allows pluggable task storage implementations + * (in-memory, database, distributed cache, etc.). + * + * @see {@linkcode InMemoryTaskStore} for a reference implementation + * @experimental + */ +export interface TaskStore { + /** + * Creates a new task with the given creation parameters and original request. + * The implementation must generate a unique taskId and createdAt timestamp. + * + * TTL Management: + * - The implementation receives the TTL suggested by the requestor via `taskParams.ttl` + * - The implementation MAY override the requested TTL (e.g., to enforce limits) + * - The actual TTL used MUST be returned in the {@linkcode Task} object + * - `null` TTL indicates unlimited task lifetime (no automatic cleanup) + * - Cleanup SHOULD occur automatically after TTL expires, regardless of task status + * + * @param taskParams - The task creation parameters from the request (ttl, pollInterval) + * @param requestId - The JSON-RPC request ID + * @param request - The original request that triggered task creation + * @param sessionId - Optional session ID for binding the task to a specific session + * @returns The created {@linkcode Task} object + */ + createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise; + + /** + * Gets the current status of a task. + * + * @param taskId - The task identifier + * @param sessionId - Optional session ID for binding the query to a specific session + * @returns The {@linkcode Task} object, or `null` if it does not exist + */ + getTask(taskId: string, sessionId?: string): Promise; + + /** + * Stores the result of a task and sets its final status. + * + * @param taskId - The task identifier + * @param status - The final status: `'completed'` for success, `'failed'` for errors + * @param result - The result to store + * @param sessionId - Optional session ID for binding the operation to a specific session + */ + storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise; + + /** + * Retrieves the stored result of a task. + * + * @param taskId - The task identifier + * @param sessionId - Optional session ID for binding the query to a specific session + * @returns The stored result + */ + getTaskResult(taskId: string, sessionId?: string): Promise; + + /** + * Updates a task's status (e.g., to `'cancelled'`, `'failed'`, `'completed'`). + * + * @param taskId - The task identifier + * @param status - The new status + * @param statusMessage - Optional diagnostic message for failed tasks or other status information + * @param sessionId - Optional session ID for binding the operation to a specific session + */ + updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise; + + /** + * Lists tasks, optionally starting from a pagination cursor. + * + * @param cursor - Optional cursor for pagination + * @param sessionId - Optional session ID for binding the query to a specific session + * @returns An object containing the tasks array and an optional nextCursor + */ + listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }>; +} + +/** + * Checks if a task status represents a terminal state. + * Terminal states are those where the task has finished and will not change. + * + * @param status - The task status to check + * @returns `true` if the status is terminal (`completed`, `failed`, or `cancelled`) + * @experimental + */ +export function isTerminal(status: Task['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'cancelled'; +} diff --git a/packages/core/src/experimental/tasks/stores/inMemory.ts b/packages/core/src/experimental/tasks/stores/inMemory.ts new file mode 100644 index 0000000..fbd7e39 --- /dev/null +++ b/packages/core/src/experimental/tasks/stores/inMemory.ts @@ -0,0 +1,313 @@ +/** + * In-memory implementations of {@linkcode TaskStore} and {@linkcode TaskMessageQueue}. + * @experimental + */ + +import type { Request, RequestId, Result, Task } from '../../../types/index.js'; +import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../interfaces.js'; +import { isTerminal } from '../interfaces.js'; + +interface StoredTask { + task: Task; + request: Request; + requestId: RequestId; + sessionId?: string; + result?: Result; +} + +/** + * In-memory {@linkcode TaskStore} implementation for development and testing. + * For production, use a database or distributed cache. + * @experimental + */ +export class InMemoryTaskStore implements TaskStore { + private tasks = new Map(); + private cleanupTimers = new Map>(); + + /** + * Generates a unique task ID using Web Crypto API. + */ + private generateTaskId(): string { + return crypto.randomUUID().replaceAll('-', ''); + } + + /** {@inheritDoc TaskStore.createTask} */ + async createTask(taskParams: CreateTaskOptions, requestId: RequestId, request: Request, sessionId?: string): Promise { + // Generate a unique task ID + const taskId = this.generateTaskId(); + + // Ensure uniqueness + if (this.tasks.has(taskId)) { + throw new Error(`Task with ID ${taskId} already exists`); + } + + const actualTtl = taskParams.ttl ?? null; + + // Create task with generated ID and timestamps + const createdAt = new Date().toISOString(); + const task: Task = { + taskId, + status: 'working', + ttl: actualTtl, + createdAt, + lastUpdatedAt: createdAt, + pollInterval: taskParams.pollInterval ?? 1000 + }; + + this.tasks.set(taskId, { + task, + request, + requestId, + sessionId + }); + + // Schedule cleanup if ttl is specified + // Cleanup occurs regardless of task status + if (actualTtl) { + const timer = setTimeout(() => { + this.tasks.delete(taskId); + this.cleanupTimers.delete(taskId); + }, actualTtl); + + this.cleanupTimers.set(taskId, timer); + } + + return task; + } + + /** + * Retrieves a stored task, enforcing session ownership when a sessionId is provided. + * Returns undefined if the task does not exist or belongs to a different session. + */ + private getStoredTask(taskId: string, sessionId?: string): StoredTask | undefined { + const stored = this.tasks.get(taskId); + if (!stored) { + return undefined; + } + // Enforce session isolation: if a sessionId is provided and the task + // was created with a sessionId, they must match. + if (sessionId !== undefined && stored.sessionId !== undefined && stored.sessionId !== sessionId) { + return undefined; + } + return stored; + } + + async getTask(taskId: string, sessionId?: string): Promise { + const stored = this.getStoredTask(taskId, sessionId); + return stored ? { ...stored.task } : null; + } + + /** {@inheritDoc TaskStore.storeTaskResult} */ + async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { + const stored = this.getStoredTask(taskId, sessionId); + if (!stored) { + throw new Error(`Task with ID ${taskId} not found`); + } + + // Don't allow storing results for tasks already in terminal state + if (isTerminal(stored.task.status)) { + throw new Error( + `Cannot store result for task ${taskId} in terminal status '${stored.task.status}'. Task results can only be stored once.` + ); + } + + stored.result = result; + stored.task.status = status; + stored.task.lastUpdatedAt = new Date().toISOString(); + + // Reset cleanup timer to start from now (if ttl is set) + if (stored.task.ttl) { + const existingTimer = this.cleanupTimers.get(taskId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.tasks.delete(taskId); + this.cleanupTimers.delete(taskId); + }, stored.task.ttl); + + this.cleanupTimers.set(taskId, timer); + } + } + + /** {@inheritDoc TaskStore.getTaskResult} */ + async getTaskResult(taskId: string, sessionId?: string): Promise { + const stored = this.getStoredTask(taskId, sessionId); + if (!stored) { + throw new Error(`Task with ID ${taskId} not found`); + } + + if (!stored.result) { + throw new Error(`Task ${taskId} has no result stored`); + } + + return stored.result; + } + + /** {@inheritDoc TaskStore.updateTaskStatus} */ + async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { + const stored = this.getStoredTask(taskId, sessionId); + if (!stored) { + throw new Error(`Task with ID ${taskId} not found`); + } + + // Don't allow transitions from terminal states + if (isTerminal(stored.task.status)) { + throw new Error( + `Cannot update task ${taskId} from terminal status '${stored.task.status}' to '${status}'. Terminal states (completed, failed, cancelled) cannot transition to other states.` + ); + } + + stored.task.status = status; + if (statusMessage) { + stored.task.statusMessage = statusMessage; + } + + stored.task.lastUpdatedAt = new Date().toISOString(); + + // If task is in a terminal state and has ttl, start cleanup timer + if (isTerminal(status) && stored.task.ttl) { + const existingTimer = this.cleanupTimers.get(taskId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + this.tasks.delete(taskId); + this.cleanupTimers.delete(taskId); + }, stored.task.ttl); + + this.cleanupTimers.set(taskId, timer); + } + } + + /** {@inheritDoc TaskStore.listTasks} */ + async listTasks(cursor?: string, sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { + const PAGE_SIZE = 10; + + // Filter tasks by session ownership before pagination + const filteredTaskIds = [...this.tasks.entries()] + .filter(([, stored]) => { + if (sessionId === undefined || stored.sessionId === undefined) { + return true; + } + return stored.sessionId === sessionId; + }) + .map(([taskId]) => taskId); + + let startIndex = 0; + if (cursor) { + const cursorIndex = filteredTaskIds.indexOf(cursor); + if (cursorIndex === -1) { + // Invalid cursor - throw error + throw new Error(`Invalid cursor: ${cursor}`); + } else { + startIndex = cursorIndex + 1; + } + } + + const pageTaskIds = filteredTaskIds.slice(startIndex, startIndex + PAGE_SIZE); + const tasks = pageTaskIds.map(taskId => { + const stored = this.tasks.get(taskId)!; + return { ...stored.task }; + }); + + const nextCursor = startIndex + PAGE_SIZE < filteredTaskIds.length ? pageTaskIds.at(-1) : undefined; + + return { tasks, nextCursor }; + } + + /** + * Cleanup all timers (useful for testing or graceful shutdown) + */ + cleanup(): void { + for (const timer of this.cleanupTimers.values()) { + clearTimeout(timer); + } + this.cleanupTimers.clear(); + this.tasks.clear(); + } + + /** + * Get all tasks (useful for debugging) + */ + getAllTasks(): Task[] { + return [...this.tasks.values()].map(stored => ({ ...stored.task })); + } +} + +/** + * In-memory {@linkcode TaskMessageQueue} implementation for development and testing. + * For production, use Redis or another distributed queue. + * @experimental + */ +export class InMemoryTaskMessageQueue implements TaskMessageQueue { + private queues = new Map(); + + /** + * Generates a queue key from taskId. + * SessionId is intentionally ignored because taskIds are globally unique + * and tasks need to be accessible across HTTP requests/sessions. + */ + private getQueueKey(taskId: string, _sessionId?: string): string { + return taskId; + } + + /** + * Gets or creates a queue for the given task and session. + */ + private getQueue(taskId: string, sessionId?: string): QueuedMessage[] { + const key = this.getQueueKey(taskId, sessionId); + let queue = this.queues.get(key); + if (!queue) { + queue = []; + this.queues.set(key, queue); + } + return queue; + } + + /** + * Adds a message to the end of the queue for a specific task. + * Atomically checks queue size and throws if maxSize would be exceeded. + * @param taskId The task identifier + * @param message The message to enqueue + * @param sessionId Optional session ID for binding the operation to a specific session + * @param maxSize Optional maximum queue size - if specified and queue is full, throws an error + * @throws Error if maxSize is specified and would be exceeded + */ + async enqueue(taskId: string, message: QueuedMessage, sessionId?: string, maxSize?: number): Promise { + const queue = this.getQueue(taskId, sessionId); + + // Atomically check size and enqueue + if (maxSize !== undefined && queue.length >= maxSize) { + throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`); + } + + queue.push(message); + } + + /** + * Removes and returns the first message from the queue for a specific task. + * @param taskId The task identifier + * @param sessionId Optional session ID for binding the query to a specific session + * @returns The first message, or `undefined` if the queue is empty + */ + async dequeue(taskId: string, sessionId?: string): Promise { + const queue = this.getQueue(taskId, sessionId); + return queue.shift(); + } + + /** + * Removes and returns all messages from the queue for a specific task. + * @param taskId The task identifier + * @param sessionId Optional session ID for binding the query to a specific session + * @returns Array of all messages that were in the queue + */ + async dequeueAll(taskId: string, sessionId?: string): Promise { + const key = this.getQueueKey(taskId, sessionId); + const queue = this.queues.get(key) ?? []; + this.queues.delete(key); + return queue; + } +} diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts new file mode 100644 index 0000000..f73ab2d --- /dev/null +++ b/packages/core/src/exports/public/index.ts @@ -0,0 +1,149 @@ +/** + * Curated public API exports for @modelcontextprotocol/core. + * + * This module defines the stable, public-facing API surface. Client and server + * packages re-export from here so that end users only see supported symbols. + * + * Internal utilities (Protocol class, stdio parsing, schema helpers, etc.) + * remain available via the internal barrel (@modelcontextprotocol/core) for + * use by client/server packages. + */ + +// Auth error classes +export { OAuthError, OAuthErrorCode } from '../../auth/errors.js'; + +// SDK error types (local errors that never cross the wire) +export type { SdkHttpErrorData } from '../../errors/sdkErrors.js'; +export { SdkError, SdkErrorCode, SdkHttpError } from '../../errors/sdkErrors.js'; + +// Auth TypeScript types (NOT Zod schemas like OAuthMetadataSchema) +export type { + AuthorizationServerMetadata, + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthClientRegistrationError, + OAuthErrorResponse, + OAuthMetadata, + OAuthProtectedResourceMetadata, + OAuthTokenRevocationRequest, + OAuthTokens, + OpenIdProviderDiscoveryMetadata, + OpenIdProviderMetadata +} from '../../shared/auth.js'; + +// Auth utilities +export { checkResourceAllowed, resourceUrlFromServerUrl } from '../../shared/authUtils.js'; + +// Metadata utilities +export { getDisplayName } from '../../shared/metadataUtils.js'; + +// Protocol types (NOT the Protocol class itself or mergeCapabilities) +export type { + BaseContext, + ClientContext, + NotificationOptions, + ProgressCallback, + ProtocolOptions, + RequestHandlerSchemas, + RequestOptions, + ServerContext +} from '../../shared/protocol.js'; +export { DEFAULT_REQUEST_TIMEOUT_MSEC } from '../../shared/protocol.js'; + +// Task manager types (NOT TaskManager class itself — internal) +export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from '../../shared/taskManager.js'; + +// Response message types +export type { + BaseResponseMessage, + ErrorMessage, + ResponseMessage, + ResultMessage, + TaskCreatedMessage, + TaskStatusMessage +} from '../../shared/responseMessage.js'; +export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; + +// stdio message framing utilities (for custom transport authors) +export { deserializeMessage, ReadBuffer, serializeMessage } from '../../shared/stdio.js'; + +// Transport types (NOT normalizeHeaders) +export type { FetchLike, Transport, TransportSendOptions } from '../../shared/transport.js'; +export { createFetchWithInit } from '../../shared/transport.js'; +export { InMemoryTransport } from '../../util/inMemory.js'; + +// URI Template +export type { Variables } from '../../shared/uriTemplate.js'; +export { UriTemplate } from '../../shared/uriTemplate.js'; + +// Types — all TypeScript types (standalone interfaces + schema-derived). +// This is the one intentional `export *`: types.ts contains only spec-derived TS +// types, and every type there should be public. See comment in types.ts. +export * from '../../types/types.js'; + +// Constants +export { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, + JSONRPC_VERSION, + LATEST_PROTOCOL_VERSION, + METHOD_NOT_FOUND, + PARSE_ERROR, + RELATED_TASK_META_KEY, + SUPPORTED_PROTOCOL_VERSIONS +} from '../../types/constants.js'; + +// Enums +export { ProtocolErrorCode } from '../../types/enums.js'; + +// Error classes +export { ProtocolError, UrlElicitationRequiredError } from '../../types/errors.js'; + +// Type guards and message parsing +export { + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + isCallToolResult, + isInitializedNotification, + isInitializeRequest, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResponse, + isJSONRPCResultResponse, + isTaskAugmentedRequestParams, + parseJSONRPCMessage +} from '../../types/guards.js'; + +// Experimental task types and classes +export { assertClientRequestTaskCapability, assertToolsCallTaskCapability } from '../../experimental/tasks/helpers.js'; +export type { + BaseQueuedMessage, + CreateTaskOptions, + CreateTaskServerContext, + QueuedError, + QueuedMessage, + QueuedNotification, + QueuedRequest, + QueuedResponse, + TaskMessageQueue, + TaskServerContext, + TaskStore, + TaskToolExecution +} from '../../experimental/tasks/interfaces.js'; +export { isTerminal } from '../../experimental/tasks/interfaces.js'; +export { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../experimental/tasks/stores/inMemory.js'; + +// Validator types and classes +export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js'; +export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js'; +export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema.js'; +export { AjvJsonSchemaValidator } from '../../validators/ajvProvider.js'; +export type { CfWorkerSchemaDraft } from '../../validators/cfWorkerProvider.js'; +// fromJsonSchema is intentionally NOT exported here — the server and client packages +// provide runtime-aware wrappers that default to the appropriate validator via _shims. +export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from '../../validators/types.js'; diff --git a/packages/core/src/exports/types/index.ts b/packages/core/src/exports/types/index.ts new file mode 100644 index 0000000..b957a88 --- /dev/null +++ b/packages/core/src/exports/types/index.ts @@ -0,0 +1 @@ +export type * from '../../types/index.js'; diff --git a/packages/core/src/index.examples.ts b/packages/core/src/index.examples.ts new file mode 100644 index 0000000..531f512 --- /dev/null +++ b/packages/core/src/index.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `index.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { AjvJsonSchemaValidator } from './validators/ajvProvider.js'; +import { CfWorkerJsonSchemaValidator } from './validators/cfWorkerProvider.js'; + +/** + * Example: AJV validator for Node.js. + */ +function validation_ajv() { + //#region validation_ajv + const validator = new AjvJsonSchemaValidator(); + //#endregion validation_ajv + return validator; +} + +/** + * Example: CfWorker validator for edge runtimes. + */ +function validation_cfWorker() { + //#region validation_cfWorker + const validator = new CfWorkerJsonSchemaValidator(); + //#endregion validation_cfWorker + return validator; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..8bcc9c9 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,56 @@ +export * from './auth/errors.js'; +export * from './errors/sdkErrors.js'; +export * from './shared/auth.js'; +export * from './shared/authUtils.js'; +export * from './shared/metadataUtils.js'; +export * from './shared/protocol.js'; +export * from './shared/responseMessage.js'; +export * from './shared/stdio.js'; +export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js'; +export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js'; +export * from './shared/toolNameValidation.js'; +export * from './shared/transport.js'; +export * from './shared/uriTemplate.js'; +export * from './types/index.js'; +export * from './util/inMemory.js'; +export * from './util/schema.js'; +export * from './util/standardSchema.js'; +export * from './util/zodCompat.js'; + +// experimental exports +export * from './experimental/index.js'; +export * from './validators/ajvProvider.js'; +// cfWorkerProvider is intentionally NOT re-exported here: it statically imports +// `@cfworker/json-schema` (an optional peer), and bundling it into the main barrel +// would force that import on all Node consumers. Import via `@modelcontextprotocol/core/validators/cfWorker` +// (used by the workerd/browser `_shims` and the public `/validators/cf-worker` subpaths). +export type { CfWorkerSchemaDraft } from './validators/cfWorkerProvider.js'; +export * from './validators/fromJsonSchema.js'; +/** + * JSON Schema validation + * + * This module provides configurable JSON Schema validation for the MCP SDK. + * Choose a validator based on your runtime environment: + * + * - {@linkcode AjvJsonSchemaValidator}: Best for Node.js (default, fastest) + * Bundled — no additional dependencies required. + * + * - `CfWorkerJsonSchemaValidator`: Best for edge runtimes + * Import from: `@modelcontextprotocol/server/validators/cf-worker` or `@modelcontextprotocol/client/validators/cf-worker` + * Bundled — no additional dependencies required. + * + * @example For Node.js with AJV + * ```ts source="./index.examples.ts#validation_ajv" + * const validator = new AjvJsonSchemaValidator(); + * ``` + * + * @example For Cloudflare Workers + * ```ts source="./index.examples.ts#validation_cfWorker" + * const validator = new CfWorkerJsonSchemaValidator(); + * ``` + * + * @module validation + */ + +// Core types only - implementations are exported via separate entry points +export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validators/types.js'; diff --git a/packages/core/src/shared/auth.ts b/packages/core/src/shared/auth.ts new file mode 100644 index 0000000..deee583 --- /dev/null +++ b/packages/core/src/shared/auth.ts @@ -0,0 +1,252 @@ +import * as z from 'zod/v4'; + +/** + * Reusable URL validation that disallows `javascript:` scheme + */ +export const SafeUrlSchema = z + .url() + .superRefine((val, ctx) => { + if (!URL.canParse(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'URL must be parseable', + fatal: true + }); + + return z.NEVER; + } + }) + .refine( + url => { + const u = new URL(url); + return u.protocol !== 'javascript:' && u.protocol !== 'data:' && u.protocol !== 'vbscript:'; + }, + { message: 'URL cannot use javascript:, data:, or vbscript: scheme' } + ); + +/** + * RFC 9728 OAuth Protected Resource Metadata + */ +export const OAuthProtectedResourceMetadataSchema = z.looseObject({ + resource: z.string().url(), + authorization_servers: z.array(SafeUrlSchema).optional(), + jwks_uri: z.string().url().optional(), + scopes_supported: z.array(z.string()).optional(), + bearer_methods_supported: z.array(z.string()).optional(), + resource_signing_alg_values_supported: z.array(z.string()).optional(), + resource_name: z.string().optional(), + resource_documentation: z.string().optional(), + resource_policy_uri: z.string().url().optional(), + resource_tos_uri: z.string().url().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + dpop_bound_access_tokens_required: z.boolean().optional() +}); + +/** + * RFC 8414 OAuth 2.0 Authorization Server Metadata + */ +export const OAuthMetadataSchema = z.looseObject({ + issuer: z.string(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + response_modes_supported: z.array(z.string()).optional(), + grant_types_supported: z.array(z.string()).optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + service_documentation: SafeUrlSchema.optional(), + revocation_endpoint: SafeUrlSchema.optional(), + revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), + revocation_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + introspection_endpoint: z.string().optional(), + introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), + introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + code_challenge_methods_supported: z.array(z.string()).optional(), + client_id_metadata_document_supported: z.boolean().optional() +}); + +/** + * OpenID Connect Discovery 1.0 Provider Metadata + * + * @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ +export const OpenIdProviderMetadataSchema = z.looseObject({ + issuer: z.string(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + userinfo_endpoint: SafeUrlSchema.optional(), + jwks_uri: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + response_modes_supported: z.array(z.string()).optional(), + grant_types_supported: z.array(z.string()).optional(), + acr_values_supported: z.array(z.string()).optional(), + subject_types_supported: z.array(z.string()), + id_token_signing_alg_values_supported: z.array(z.string()), + id_token_encryption_alg_values_supported: z.array(z.string()).optional(), + id_token_encryption_enc_values_supported: z.array(z.string()).optional(), + userinfo_signing_alg_values_supported: z.array(z.string()).optional(), + userinfo_encryption_alg_values_supported: z.array(z.string()).optional(), + userinfo_encryption_enc_values_supported: z.array(z.string()).optional(), + request_object_signing_alg_values_supported: z.array(z.string()).optional(), + request_object_encryption_alg_values_supported: z.array(z.string()).optional(), + request_object_encryption_enc_values_supported: z.array(z.string()).optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + display_values_supported: z.array(z.string()).optional(), + claim_types_supported: z.array(z.string()).optional(), + claims_supported: z.array(z.string()).optional(), + service_documentation: z.string().optional(), + claims_locales_supported: z.array(z.string()).optional(), + ui_locales_supported: z.array(z.string()).optional(), + claims_parameter_supported: z.boolean().optional(), + request_parameter_supported: z.boolean().optional(), + request_uri_parameter_supported: z.boolean().optional(), + require_request_uri_registration: z.boolean().optional(), + op_policy_uri: SafeUrlSchema.optional(), + op_tos_uri: SafeUrlSchema.optional(), + client_id_metadata_document_supported: z.boolean().optional() +}); + +/** + * OpenID Connect Discovery metadata that may include OAuth 2.0 fields + * This schema represents the real-world scenario where OIDC providers + * return a mix of OpenID Connect and OAuth 2.0 metadata fields + */ +export const OpenIdProviderDiscoveryMetadataSchema = z.object({ + ...OpenIdProviderMetadataSchema.shape, + ...OAuthMetadataSchema.pick({ + code_challenge_methods_supported: true + }).shape +}); + +/** + * OAuth 2.1 token response + */ +export const OAuthTokensSchema = z + .object({ + access_token: z.string(), + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect + token_type: z.string(), + expires_in: z.coerce.number().optional(), + scope: z.string().optional(), + refresh_token: z.string().optional() + }) + .strip(); + +/** + * RFC 8693 §2.2.1 Token Exchange response for ID-JAG tokens. + * + * `token_type` is intentionally optional: per RFC 8693 §2.2.1 it is informational when + * the issued token is not an access token, and per RFC 6749 §5.1 it is case-insensitive, + * so strict checking rejects conformant IdPs. + */ +export const IdJagTokenExchangeResponseSchema = z + .object({ + issued_token_type: z.literal('urn:ietf:params:oauth:token-type:id-jag'), + access_token: z.string(), + token_type: z.string().optional(), + expires_in: z.number().optional(), + scope: z.string().optional() + }) + .strip(); + +export type IdJagTokenExchangeResponse = z.infer; + +/** + * OAuth 2.1 error response + */ +export const OAuthErrorResponseSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), + error_uri: z.string().optional() +}); + +/** + * Optional version of {@linkcode SafeUrlSchema} that allows empty string for backward compatibility on `tos_uri` and `logo_uri` + */ +// eslint-disable-next-line unicorn/no-useless-undefined +export const OptionalSafeUrlSchema = SafeUrlSchema.optional().or(z.literal('').transform(() => undefined)); + +/** + * RFC 7591 OAuth 2.0 Dynamic Client Registration metadata + */ +export const OAuthClientMetadataSchema = z + .object({ + redirect_uris: z.array(SafeUrlSchema), + token_endpoint_auth_method: z.string().optional(), + grant_types: z.array(z.string()).optional(), + response_types: z.array(z.string()).optional(), + client_name: z.string().optional(), + client_uri: SafeUrlSchema.optional(), + logo_uri: OptionalSafeUrlSchema, + scope: z.string().optional(), + contacts: z.array(z.string()).optional(), + tos_uri: OptionalSafeUrlSchema, + policy_uri: z.string().optional(), + jwks_uri: SafeUrlSchema.optional(), + jwks: z.any().optional(), + software_id: z.string().optional(), + software_version: z.string().optional(), + software_statement: z.string().optional() + }) + .strip(); + +/** + * RFC 7591 OAuth 2.0 Dynamic Client Registration client information + */ +export const OAuthClientInformationSchema = z + .object({ + client_id: z.string(), + client_secret: z.string().optional(), + client_id_issued_at: z.number().optional(), + client_secret_expires_at: z.number().optional() + }) + .strip(); + +/** + * RFC 7591 OAuth 2.0 Dynamic Client Registration full response (client information plus metadata) + */ +export const OAuthClientInformationFullSchema = OAuthClientMetadataSchema.merge(OAuthClientInformationSchema); + +/** + * RFC 7591 OAuth 2.0 Dynamic Client Registration error response + */ +export const OAuthClientRegistrationErrorSchema = z + .object({ + error: z.string(), + error_description: z.string().optional() + }) + .strip(); + +/** + * RFC 7009 OAuth 2.0 Token Revocation request + */ +export const OAuthTokenRevocationRequestSchema = z + .object({ + token: z.string(), + token_type_hint: z.string().optional() + }) + .strip(); + +export type OAuthMetadata = z.infer; +export type OpenIdProviderMetadata = z.infer; +export type OpenIdProviderDiscoveryMetadata = z.infer; + +export type OAuthTokens = z.infer; +export type OAuthErrorResponse = z.infer; +export type OAuthClientMetadata = z.infer; +export type OAuthClientInformation = z.infer; +export type OAuthClientInformationFull = z.infer; +export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientInformationFull; +export type OAuthClientRegistrationError = z.infer; +export type OAuthTokenRevocationRequest = z.infer; +export type OAuthProtectedResourceMetadata = z.infer; + +// Unified type for authorization server metadata +export type AuthorizationServerMetadata = OAuthMetadata | OpenIdProviderDiscoveryMetadata; diff --git a/packages/core/src/shared/authUtils.ts b/packages/core/src/shared/authUtils.ts new file mode 100644 index 0000000..3083e42 --- /dev/null +++ b/packages/core/src/shared/authUtils.ts @@ -0,0 +1,57 @@ +/** + * Utilities for handling OAuth resource URIs. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * {@link https://datatracker.ietf.org/doc/html/rfc8707#section-2 | RFC 8707 section 2} + * states that resource URIs "MUST NOT include a fragment component". + * Keeps everything else unchanged (scheme, domain, port, path, query). + */ +export function resourceUrlFromServerUrl(url: URL | string): URL { + const resourceURL = typeof url === 'string' ? new URL(url) : new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; +} + +/** + * Checks if a requested resource URL matches a configured resource URL. + * A requested resource matches if it has the same scheme, domain, port, + * and its path starts with the configured resource's path. + * + * @param options - The options object + * @param options.requestedResource - The resource URL being requested + * @param options.configuredResource - The resource URL that has been configured + * @returns true if the requested resource matches the configured resource, false otherwise + */ +export function checkResourceAllowed({ + requestedResource, + configuredResource +}: { + requestedResource: URL | string; + configuredResource: URL | string; +}): boolean { + const requested = typeof requestedResource === 'string' ? new URL(requestedResource) : new URL(requestedResource.href); + const configured = typeof configuredResource === 'string' ? new URL(configuredResource) : new URL(configuredResource.href); + + // Compare the origin (scheme, domain, and port) + if (requested.origin !== configured.origin) { + return false; + } + + // Handle cases like requested=/foo and configured=/foo/ + if (requested.pathname.length < configured.pathname.length) { + return false; + } + + // Check if the requested path starts with the configured path + // Ensure both paths end with / for proper comparison + // This ensures that if we have paths like "/api" and "/api/users", + // we properly detect that "/api/users" is a subpath of "/api" + // By adding a trailing slash if missing, we avoid false positives + // where paths like "/api123" would incorrectly match "/api" + const requestedPath = requested.pathname.endsWith('/') ? requested.pathname : requested.pathname + '/'; + const configuredPath = configured.pathname.endsWith('/') ? configured.pathname : configured.pathname + '/'; + + return requestedPath.startsWith(configuredPath); +} diff --git a/packages/core/src/shared/metadataUtils.ts b/packages/core/src/shared/metadataUtils.ts new file mode 100644 index 0000000..1b11660 --- /dev/null +++ b/packages/core/src/shared/metadataUtils.ts @@ -0,0 +1,26 @@ +import type { BaseMetadata } from '../types/index.js'; + +/** + * Utilities for working with {@linkcode BaseMetadata} objects. + */ + +/** + * Gets the display name for an object with {@linkcode BaseMetadata}. + * For tools, the precedence is: `title` → {@linkcode index.ToolAnnotations | annotations}.`title` → `name` + * For other objects: `title` → `name` + * This implements the spec requirement: "if no title is provided, name should be used for display purposes" + */ +export function getDisplayName(metadata: BaseMetadata | (BaseMetadata & { annotations?: { title?: string } })): string { + // First check for title (not undefined and not empty string) + if (metadata.title !== undefined && metadata.title !== '') { + return metadata.title; + } + + // Then check for annotations.title (only present in Tool objects) + if ('annotations' in metadata && metadata.annotations?.title) { + return metadata.annotations.title; + } + + // Finally fall back to name + return metadata.name; +} diff --git a/packages/core/src/shared/protocol.examples.ts b/packages/core/src/shared/protocol.examples.ts new file mode 100644 index 0000000..ba3a701 --- /dev/null +++ b/packages/core/src/shared/protocol.examples.ts @@ -0,0 +1,29 @@ +/** + * Type-checked examples for `protocol.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import * as z from 'zod/v4'; + +import type { BaseContext, Protocol } from './protocol.js'; + +/** + * Example: registering a handler for a custom (non-spec) request method. + */ +function Protocol_setRequestHandler_customMethod(protocol: Protocol) { + //#region Protocol_setRequestHandler_customMethod + const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); + const SearchResult = z.object({ hits: z.array(z.string()) }); + + protocol.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, _ctx) => { + return { hits: [`result for ${params.query}`] }; + }); + //#endregion Protocol_setRequestHandler_customMethod + void protocol; +} + +void Protocol_setRequestHandler_customMethod; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts new file mode 100644 index 0000000..361bd6f --- /dev/null +++ b/packages/core/src/shared/protocol.ts @@ -0,0 +1,1236 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { + AuthInfo, + CancelledNotification, + ClientCapabilities, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, + LoggingLevel, + MessageExtraInfo, + Notification, + NotificationMethod, + NotificationTypeMap, + Progress, + ProgressNotification, + RelatedTaskMetadata, + Request, + RequestId, + RequestMeta, + RequestMethod, + RequestTypeMap, + Result, + ResultTypeMap, + ServerCapabilities, + TaskCreationParams +} from '../types/index.js'; +import { + getNotificationSchema, + getRequestSchema, + getResultSchema, + isJSONRPCErrorResponse, + isJSONRPCNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + ProtocolError, + ProtocolErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '../types/index.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import { isStandardSchema, validateStandardSchema } from '../util/standardSchema.js'; +import type { TaskContext, TaskManagerHost, TaskManagerOptions, TaskRequestOptions } from './taskManager.js'; +import { NullTaskManager, TaskManager } from './taskManager.js'; +import type { Transport, TransportSendOptions } from './transport.js'; + +/** + * Callback for progress notifications. + */ +export type ProgressCallback = (progress: Progress) => void; + +/** + * Additional initialization options. + */ +export type ProtocolOptions = { + /** + * Protocol versions supported. First version is preferred (sent by client, + * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. + * + * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} + */ + supportedProtocolVersions?: string[]; + + /** + * Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities. + * + * Note that this DOES NOT affect checking of _local_ side capabilities, as it is considered a logic error to mis-specify those. + * + * Currently this defaults to `false`, for backwards compatibility with SDK versions that did not advertise capabilities correctly. In future, this will default to `true`. + */ + enforceStrictCapabilities?: boolean; + /** + * An array of notification method names that should be automatically debounced. + * Any notifications with a method in this list will be coalesced if they + * occur in the same tick of the event loop. + * e.g., `['notifications/tools/list_changed']` + */ + debouncedNotificationMethods?: string[]; + + /** + * Runtime configuration for task management. + * If provided, creates a TaskManager with the given options; otherwise a NullTaskManager is used. + * + * Capability assertions are wired automatically from the protocol's + * `assertTaskCapability()` and `assertTaskHandlerCapability()` methods, + * so they should NOT be included here. + */ + tasks?: TaskManagerOptions; +}; + +/** + * The default request timeout, in milliseconds. + */ +export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; + +/** + * Options that can be given per request. + */ +export type RequestOptions = { + /** + * If set, requests progress notifications from the remote end (if supported). When progress notifications are received, this callback will be invoked. + * + * For task-augmented requests: progress notifications continue after {@linkcode CreateTaskResult} is returned and stop automatically when the task reaches a terminal status. + */ + onprogress?: ProgressCallback; + + /** + * Can be used to cancel an in-flight request. This will cause an `AbortError` to be raised from {@linkcode Protocol.request | request()}. + */ + signal?: AbortSignal; + + /** + * A timeout (in milliseconds) for this request. If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised from {@linkcode Protocol.request | request()}. + * + * If not specified, {@linkcode DEFAULT_REQUEST_TIMEOUT_MSEC} will be used as the timeout. + */ + timeout?: number; + + /** + * If `true`, receiving a progress notification will reset the request timeout. + * This is useful for long-running operations that send periodic progress updates. + * Default: `false` + */ + resetTimeoutOnProgress?: boolean; + + /** + * Maximum total time (in milliseconds) to wait for a response. + * If exceeded, an {@linkcode SdkError} with code {@linkcode SdkErrorCode.RequestTimeout} will be raised, regardless of progress notifications. + * If not specified, there is no maximum total timeout. + */ + maxTotalTimeout?: number; + + /** + * If provided, augments the request with task creation parameters to enable call-now, fetch-later execution patterns. + */ + task?: TaskCreationParams; + + /** + * If provided, associates this request with a related task. + */ + relatedTask?: RelatedTaskMetadata; +} & TransportSendOptions; + +/** + * Options that can be given per notification. + */ +export type NotificationOptions = { + /** + * May be used to indicate to the transport which incoming request to associate this outgoing notification with. + */ + relatedRequestId?: RequestId; + + /** + * If provided, associates this notification with a related task. + */ + relatedTask?: RelatedTaskMetadata; +}; + +/** + * Base context provided to all request handlers. + */ +export type BaseContext = { + /** + * The session ID from the transport, if available. + */ + sessionId?: string; + + /** + * Information about the MCP request being handled. + */ + mcpReq: { + /** + * The JSON-RPC ID of the request being handled. + */ + id: RequestId; + + /** + * The method name of the request (e.g., 'tools/call', 'ping'). + */ + method: string; + + /** + * Metadata from the original request. + */ + _meta?: RequestMeta; + + /** + * An abort signal used to communicate if the request was cancelled from the sender's side. + */ + signal: AbortSignal; + + /** + * Sends a request that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + * + * For spec methods the result type is inferred from the method name. + * For custom (non-spec) methods, pass a result schema as the second argument. + */ + send: { + ( + request: { method: M; params?: Record }, + options?: TaskRequestOptions + ): Promise; + ( + request: Request, + resultSchema: T, + options?: TaskRequestOptions + ): Promise>; + }; + + /** + * Sends a notification that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + notify: (notification: Notification) => Promise; + }; + + /** + * HTTP transport information, only available when using an HTTP-based transport. + */ + http?: { + /** + * Information about a validated access token, provided to request handlers. + */ + authInfo?: AuthInfo; + }; + + /** + * Task context, available when task storage is configured. + */ + task?: TaskContext; +}; + +/** + * Context provided to server-side request handlers, extending {@linkcode BaseContext} with server-specific fields. + */ +export type ServerContext = BaseContext & { + mcpReq: { + /** + * Send a log message notification to the client. + * Respects the client's log level filter set via logging/setLevel. + */ + log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; + + /** + * Send an elicitation request to the client, requesting user input. + */ + elicitInput: (params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions) => Promise; + + /** + * Request LLM sampling from the client. + */ + requestSampling: ( + params: CreateMessageRequest['params'], + options?: RequestOptions + ) => Promise; + }; + + http?: { + /** + * The original HTTP request. + */ + req?: globalThis.Request; + + /** + * Closes the SSE stream for this request, triggering client reconnection. + * Only available when using a StreamableHTTPServerTransport with eventStore configured. + */ + closeSSE?: () => void; + + /** + * Closes the standalone GET SSE stream, triggering client reconnection. + * Only available when using a StreamableHTTPServerTransport with eventStore configured. + */ + closeStandaloneSSE?: () => void; + }; +}; + +/** + * Context provided to client-side request handlers. + */ +export type ClientContext = BaseContext; + +/** + * Information about a request's timeout state + */ +type TimeoutInfo = { + timeoutId: ReturnType; + startTime: number; + timeout: number; + maxTotalTimeout?: number; + resetTimeoutOnProgress: boolean; + onTimeout: () => void; +}; + +/** + * Implements MCP protocol framing on top of a pluggable transport, including + * features like request/response linking, notifications, and progress. + * + * `Protocol` is abstract; `Client` and `Server` are the concrete role-specific + * implementations most code should use. + */ +export abstract class Protocol { + private _transport?: Transport; + private _requestMessageId = 0; + private _requestHandlers: Map Promise> = new Map(); + private _requestHandlerAbortControllers: Map = new Map(); + private _notificationHandlers: Map Promise> = new Map(); + private _responseHandlers: Map void> = new Map(); + private _progressHandlers: Map = new Map(); + private _timeoutInfo: Map = new Map(); + private _pendingDebouncedNotifications = new Set(); + + private _taskManager: TaskManager; + + protected _supportedProtocolVersions: string[]; + + /** + * Callback for when the connection is closed for any reason. + * + * This is invoked when {@linkcode Protocol.close | close()} is called as well. + */ + onclose?: () => void; + + /** + * Callback for when an error occurs. + * + * Note that errors are not necessarily fatal; they are used for reporting any kind of exceptional condition out of band. + */ + onerror?: (error: Error) => void; + + /** + * A handler to invoke for any request types that do not have their own handler installed. + */ + fallbackRequestHandler?: (request: JSONRPCRequest, ctx: ContextT) => Promise; + + /** + * A handler to invoke for any notification types that do not have their own handler installed. + */ + fallbackNotificationHandler?: (notification: Notification) => Promise; + + constructor(private _options?: ProtocolOptions) { + this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + + // Create TaskManager from protocol options + this._taskManager = _options?.tasks ? new TaskManager(_options.tasks) : new NullTaskManager(); + this._bindTaskManager(); + + this.setNotificationHandler('notifications/cancelled', notification => { + this._oncancel(notification); + }); + + this.setNotificationHandler('notifications/progress', notification => { + this._onprogress(notification); + }); + + this.setRequestHandler( + 'ping', + // Automatic pong by default. + _request => ({}) as Result + ); + } + + /** + * Access the TaskManager for task orchestration. + * Always available; returns a NullTaskManager when no task store is configured. + */ + get taskManager(): TaskManager { + return this._taskManager; + } + + private _bindTaskManager(): void { + const taskManager = this._taskManager; + const host: TaskManagerHost = { + request: (request, resultSchema, options) => this._requestWithSchema(request, resultSchema, options), + notification: (notification, options) => this.notification(notification, options), + reportError: error => this._onerror(error), + removeProgressHandler: token => this._progressHandlers.delete(token), + registerHandler: (method, handler) => { + const schema = getRequestSchema(method as RequestMethod); + this._requestHandlers.set(method, (request, ctx) => { + // Validate request params via Zod (strips jsonrpc/id, so we pass original to handler) + schema.parse(request); + return handler(request, ctx); + }); + }, + sendOnResponseStream: async (message, relatedRequestId) => { + await this._transport?.send(message, { relatedRequestId }); + }, + enforceStrictCapabilities: this._options?.enforceStrictCapabilities === true, + assertTaskCapability: method => this.assertTaskCapability(method), + assertTaskHandlerCapability: method => this.assertTaskHandlerCapability(method) + }; + taskManager.bind(host); + } + + /** + * Builds the context object for request handlers. Subclasses must override + * to return the appropriate context type (e.g., ServerContext adds HTTP request info). + */ + protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + + private async _oncancel(notification: CancelledNotification): Promise { + if (!notification.params.requestId) { + return; + } + // Handle request cancellation + const controller = this._requestHandlerAbortControllers.get(notification.params.requestId); + controller?.abort(notification.params.reason); + } + + private _setupTimeout( + messageId: number, + timeout: number, + maxTotalTimeout: number | undefined, + onTimeout: () => void, + resetTimeoutOnProgress: boolean = false + ) { + this._timeoutInfo.set(messageId, { + timeoutId: setTimeout(onTimeout, timeout), + startTime: Date.now(), + timeout, + maxTotalTimeout, + resetTimeoutOnProgress, + onTimeout + }); + } + + private _resetTimeout(messageId: number): boolean { + const info = this._timeoutInfo.get(messageId); + if (!info) return false; + + const totalElapsed = Date.now() - info.startTime; + if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) { + this._timeoutInfo.delete(messageId); + throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + maxTotalTimeout: info.maxTotalTimeout, + totalElapsed + }); + } + + clearTimeout(info.timeoutId); + info.timeoutId = setTimeout(info.onTimeout, info.timeout); + return true; + } + + private _cleanupTimeout(messageId: number) { + const info = this._timeoutInfo.get(messageId); + if (info) { + clearTimeout(info.timeoutId); + this._timeoutInfo.delete(messageId); + } + } + + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * + * The caller assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. + */ + async connect(transport: Transport): Promise { + this._transport = transport; + const _onclose = this.transport?.onclose; + this._transport.onclose = () => { + try { + _onclose?.(); + } finally { + this._onclose(); + } + }; + + const _onerror = this.transport?.onerror; + this._transport.onerror = (error: Error) => { + _onerror?.(error); + this._onerror(error); + }; + + const _onmessage = this._transport?.onmessage; + this._transport.onmessage = (message, extra) => { + _onmessage?.(message, extra); + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._onresponse(message); + } else if (isJSONRPCRequest(message)) { + this._onrequest(message, extra); + } else if (isJSONRPCNotification(message)) { + this._onnotification(message); + } else { + this._onerror(new Error(`Unknown message type: ${JSON.stringify(message)}`)); + } + }; + + // Pass supported protocol versions to transport for header validation + transport.setSupportedProtocolVersions?.(this._supportedProtocolVersions); + + await this._transport.start(); + } + + private _onclose(): void { + const responseHandlers = this._responseHandlers; + this._responseHandlers = new Map(); + this._progressHandlers.clear(); + this._taskManager.onClose(); + this._pendingDebouncedNotifications.clear(); + + for (const info of this._timeoutInfo.values()) { + clearTimeout(info.timeoutId); + } + this._timeoutInfo.clear(); + + const requestHandlerAbortControllers = this._requestHandlerAbortControllers; + this._requestHandlerAbortControllers = new Map(); + + const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); + + this._transport = undefined; + + try { + this.onclose?.(); + } finally { + for (const handler of responseHandlers.values()) { + handler(error); + } + + for (const controller of requestHandlerAbortControllers.values()) { + controller.abort(error); + } + } + } + + private _onerror(error: Error): void { + this.onerror?.(error); + } + + private _onnotification(notification: JSONRPCNotification): void { + const handler = this._notificationHandlers.get(notification.method) ?? this.fallbackNotificationHandler; + + // Ignore notifications not being subscribed to. + if (handler === undefined) { + return; + } + + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. + Promise.resolve() + .then(() => handler(notification)) + .catch(error => this._onerror(new Error(`Uncaught error in notification handler: ${error}`))); + } + + private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + // Capture the current transport at request time to ensure responses go to the correct client + const capturedTransport = this._transport; + + // Delegate context extraction to module (if registered) + const inboundCtx = { + sessionId: capturedTransport?.sessionId, + sendNotification: (notification: Notification, options?: NotificationOptions) => + this.notification(notification, { ...options, relatedRequestId: request.id }), + sendRequest: (r: Request, resultSchema: U, options?: RequestOptions) => + this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }) + }; + + // Delegate to TaskManager for task context, wrapped send/notify, and response routing + const taskResult = this._taskManager.processInboundRequest(request, inboundCtx); + const sendNotification = taskResult.sendNotification; + const sendRequest = taskResult.sendRequest; + const taskContext = taskResult.taskContext; + const routeResponse = taskResult.routeResponse; + const validators: Array<() => void> = []; + if (taskResult.validateInbound) validators.push(taskResult.validateInbound); + + if (handler === undefined) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: request.id, + error: { + code: ProtocolErrorCode.MethodNotFound, + message: 'Method not found' + } + }; + + // Queue or send the error response based on whether this is a task-related request + routeResponse(errorResponse) + .then(routed => { + if (!routed) { + capturedTransport + ?.send(errorResponse) + .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + } + }) + .catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); + return; + } + + const abortController = new AbortController(); + this._requestHandlerAbortControllers.set(request.id, abortController); + + const baseCtx: BaseContext = { + sessionId: capturedTransport?.sessionId, + mcpReq: { + id: request.id, + method: request.method, + _meta: request.params?._meta, + signal: abortController.signal, + // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow + // literals can't carry overload signatures, so the inferred single-signature type isn't assignable to + // that overloaded property type. The cast is sound: this impl dispatches both overload paths via the + // isStandardSchema guard, and sendRequest validates the result against the resolved schema either way. + send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | TaskRequestOptions, maybeOptions?: TaskRequestOptions) => { + if (isStandardSchema(schemaOrOptions)) { + return sendRequest(r, schemaOrOptions, maybeOptions); + } + const resultSchema = getResultSchema(r.method); + if (!resultSchema) { + throw new TypeError( + `'${r.method}' is not a spec method; pass a result schema as the second argument to ctx.mcpReq.send().` + ); + } + return sendRequest(r, resultSchema, schemaOrOptions); + }) as BaseContext['mcpReq']['send'], + notify: sendNotification + }, + http: extra?.authInfo ? { authInfo: extra.authInfo } : undefined, + task: taskContext + }; + const ctx = this.buildContext(baseCtx, extra); + + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. + Promise.resolve() + .then(() => { + for (const validate of validators) { + validate(); + } + }) + .then(() => handler(request, ctx)) + .then( + async result => { + if (abortController.signal.aborted) { + // Request was cancelled + return; + } + + const response: JSONRPCResponse = { + result, + jsonrpc: '2.0', + id: request.id + }; + + // Queue or send the response based on whether this is a task-related request + const routed = await routeResponse(response); + if (!routed) { + await capturedTransport?.send(response); + } + }, + async error => { + if (abortController.signal.aborted) { + // Request was cancelled + return; + } + + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, + message: error.message ?? 'Internal error', + ...(error['data'] !== undefined && { data: error['data'] }) + } + }; + + // Queue or send the error response based on whether this is a task-related request + const routed = await routeResponse(errorResponse); + if (!routed) { + await capturedTransport?.send(errorResponse); + } + } + ) + .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) + .finally(() => { + if (this._requestHandlerAbortControllers.get(request.id) === abortController) { + this._requestHandlerAbortControllers.delete(request.id); + } + }); + } + + private _onprogress(notification: ProgressNotification): void { + const { progressToken, ...params } = notification.params; + const messageId = Number(progressToken); + + const handler = this._progressHandlers.get(messageId); + if (!handler) { + this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(notification)}`)); + return; + } + + const responseHandler = this._responseHandlers.get(messageId); + const timeoutInfo = this._timeoutInfo.get(messageId); + + if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) { + try { + this._resetTimeout(messageId); + } catch (error) { + // Clean up if maxTotalTimeout was exceeded + this._responseHandlers.delete(messageId); + this._progressHandlers.delete(messageId); + this._cleanupTimeout(messageId); + responseHandler(error as Error); + return; + } + } + + handler(params); + } + + private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { + const messageId = Number(response.id); + + // Delegate to TaskManager for task-related response handling + const taskResult = this._taskManager.processInboundResponse(response, messageId); + if (taskResult.consumed) return; + const preserveProgress = taskResult.preserveProgress; + + const handler = this._responseHandlers.get(messageId); + if (handler === undefined) { + this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(response)}`)); + return; + } + + this._responseHandlers.delete(messageId); + this._cleanupTimeout(messageId); + + // Keep progress handler alive for CreateTaskResult responses + if (!preserveProgress) { + this._progressHandlers.delete(messageId); + } + + if (isJSONRPCResultResponse(response)) { + handler(response); + } else { + const error = ProtocolError.fromError(response.error.code, response.error.message, response.error.data); + handler(error); + } + } + + get transport(): Transport | undefined { + return this._transport; + } + + /** + * Closes the connection. + */ + async close(): Promise { + await this._transport?.close(); + } + + /** + * A method to check if a capability is supported by the remote side, for the given method to be called. + * + * This should be implemented by subclasses. + */ + protected abstract assertCapabilityForMethod(method: RequestMethod | string): void; + + /** + * A method to check if a notification is supported by the local side, for the given method to be sent. + * + * This should be implemented by subclasses. + */ + protected abstract assertNotificationCapability(method: NotificationMethod | string): void; + + /** + * A method to check if a request handler is supported by the local side, for the given method to be handled. + * + * This should be implemented by subclasses. + */ + protected abstract assertRequestHandlerCapability(method: string): void; + + /** + * A method to check if the remote side supports task creation for the given method. + * + * Called when sending a task-augmented outbound request (only when enforceStrictCapabilities is true). + * This should be implemented by subclasses. + */ + protected abstract assertTaskCapability(method: string): void; + + /** + * A method to check if this side supports handling task creation for the given method. + * + * Called when receiving a task-augmented inbound request. + * This should be implemented by subclasses. + */ + protected abstract assertTaskHandlerCapability(method: string): void; + + /** + * Sends a request and waits for a response. + * + * For spec methods the result schema is resolved automatically from the method name + * and the return type is method-keyed. For custom (non-spec) methods, pass a + * `resultSchema` as the second argument; the response is validated against it and + * the return type is inferred from the schema. + * + * Do not use this method to emit notifications! Use {@linkcode Protocol.notification | notification()} instead. + */ + request( + request: { method: M; params?: Record }, + options?: RequestOptions + ): Promise; + request( + request: Request, + resultSchema: T, + options?: RequestOptions + ): Promise>; + request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { + if (isStandardSchema(schemaOrOptions)) { + return this._requestWithSchema(request, schemaOrOptions, maybeOptions); + } + const resultSchema = getResultSchema(request.method); + if (!resultSchema) { + throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); + } + return this._requestWithSchema(request, resultSchema, schemaOrOptions); + } + + /** + * Sends a request and waits for a response, using the provided schema for validation. + * + * This is the internal implementation used by SDK methods that need to specify + * a particular result schema (e.g., for compatibility or task-specific schemas). + */ + protected _requestWithSchema( + request: Request, + resultSchema: T, + options?: RequestOptions + ): Promise> { + const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; + + let onAbort: (() => void) | undefined; + let cleanupMessageId: number | undefined; + + // Send the request + return new Promise>((resolve, reject) => { + const earlyReject = (error: unknown) => { + reject(error); + }; + + if (!this._transport) { + earlyReject(new Error('Not connected')); + return; + } + + if (this._options?.enforceStrictCapabilities === true) { + try { + this.assertCapabilityForMethod(request.method); + } catch (error) { + earlyReject(error); + return; + } + } + + options?.signal?.throwIfAborted(); + + const messageId = this._requestMessageId++; + cleanupMessageId = messageId; + const jsonrpcRequest: JSONRPCRequest = { + ...request, + jsonrpc: '2.0', + id: messageId + }; + + if (options?.onprogress) { + this._progressHandlers.set(messageId, options.onprogress); + jsonrpcRequest.params = { + ...request.params, + _meta: { + ...request.params?._meta, + progressToken: messageId + } + }; + } + + let responseReceived = false; + + const cancel = (reason: unknown) => { + if (responseReceived) { + return; + } + this._progressHandlers.delete(messageId); + + this._transport + ?.send( + { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }, + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + + // Wrap the reason in an SdkError if it isn't already + const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); + reject(error); + }; + + this._responseHandlers.set(messageId, response => { + if (options?.signal?.aborted) { + return; + } + responseReceived = true; + + if (response instanceof Error) { + return reject(response); + } + + validateStandardSchema(resultSchema, response.result).then(parseResult => { + if (parseResult.success) { + resolve(parseResult.data); + } else { + reject(new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: ${parseResult.error}`)); + } + }, reject); + }); + + onAbort = () => cancel(options?.signal?.reason); + options?.signal?.addEventListener('abort', onAbort, { once: true }); + + const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout })); + + this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); + + // Delegate task augmentation and routing to module (if registered) + const responseHandler = (response: JSONRPCResultResponse | Error) => { + const handler = this._responseHandlers.get(messageId); + if (handler) { + handler(response); + } else { + this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); + } + }; + + let outboundQueued = false; + try { + const taskResult = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, error => { + this._progressHandlers.delete(messageId); + reject(error); + }); + if (taskResult.queued) { + outboundQueued = true; + } + } catch (error) { + this._progressHandlers.delete(messageId); + reject(error); + return; + } + + if (!outboundQueued) { + // No related task or no module - send through transport normally + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._progressHandlers.delete(messageId); + reject(error); + }); + } + }).finally(() => { + // Per-request cleanup that must run on every exit path. Consolidated + // here so new exit paths added to the promise body can't forget it. + // _progressHandlers is NOT cleaned up here: _onresponse deletes it + // conditionally (preserveProgress for task flows), and error paths + // above delete it inline since no task exists in those cases. + if (onAbort) { + options?.signal?.removeEventListener('abort', onAbort); + } + if (cleanupMessageId !== undefined) { + this._responseHandlers.delete(cleanupMessageId); + this._cleanupTimeout(cleanupMessageId); + } + }); + } + + /** + * Emits a notification, which is a one-way message that does not expect a response. + */ + async notification(notification: Notification, options?: NotificationOptions): Promise { + if (!this._transport) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + + this.assertNotificationCapability(notification.method); + + // Delegate task-related notification routing and JSONRPC building to TaskManager + const taskResult = await this._taskManager.processOutboundNotification(notification, options); + const queued = taskResult.queued; + const jsonrpcNotification = taskResult.queued ? undefined : taskResult.jsonrpcNotification; + + if (queued) { + // Don't send through transport - queued messages are delivered via tasks/result only + return; + } + + const debouncedMethods = this._options?.debouncedNotificationMethods ?? []; + // A notification can only be debounced if it's in the list AND it's "simple" + // (i.e., has no parameters and no related request ID or related task that could be lost). + const canDebounce = + debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId && !options?.relatedTask; + + if (canDebounce) { + // If a notification of this type is already scheduled, do nothing. + if (this._pendingDebouncedNotifications.has(notification.method)) { + return; + } + + // Mark this notification type as pending. + this._pendingDebouncedNotifications.add(notification.method); + + // Schedule the actual send to happen in the next microtask. + // This allows all synchronous calls in the current event loop tick to be coalesced. + Promise.resolve().then(() => { + // Un-mark the notification so the next one can be scheduled. + this._pendingDebouncedNotifications.delete(notification.method); + + // SAFETY CHECK: If the connection was closed while this was pending, abort. + if (!this._transport) { + return; + } + + // Send the notification, but don't await it here to avoid blocking. + // Handle potential errors with a .catch(). + this._transport?.send(jsonrpcNotification!, options).catch(error => this._onerror(error)); + }); + + // Return immediately. + return; + } + + await this._transport.send(jsonrpcNotification!, options); + } + + /** + * Registers a handler to invoke when this protocol object receives a request with the given method. + * + * Note that this will replace any previous request handler for the same method. + * + * For spec methods, pass `(method, handler)`; the request is parsed with the spec + * schema and the handler receives the typed `Request`. For custom (non-spec) + * methods, pass `(method, schemas, handler)`; `params` are validated against + * `schemas.params` and the handler receives the parsed params object directly. + * Supplying `schemas.result` types the handler's return value. + * + * @example Custom request method + * ```ts source="./protocol.examples.ts#Protocol_setRequestHandler_customMethod" + * const SearchParams = z.object({ query: z.string(), limit: z.number().optional() }); + * const SearchResult = z.object({ hits: z.array(z.string()) }); + * + * protocol.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, _ctx) => { + * return { hits: [`result for ${params.query}`] }; + * }); + * ``` + */ + setRequestHandler( + method: M, + handler: (request: RequestTypeMap[M], ctx: ContextT) => ResultTypeMap[M] | Promise + ): void; + setRequestHandler

( + method: string, + schemas: { params: P; result?: R }, + handler: (params: StandardSchemaV1.InferOutput

, ctx: ContextT) => InferHandlerResult | Promise> + ): void; + setRequestHandler( + method: string, + schemasOrHandler: RequestHandlerSchemas | ((request: unknown, ctx: ContextT) => Result | Promise), + maybeHandler?: (params: unknown, ctx: ContextT) => Result | Promise + ): void { + this.assertRequestHandlerCapability(method); + + let stored: (request: JSONRPCRequest, ctx: ContextT) => Promise; + + if (typeof schemasOrHandler === 'function') { + const schema = getRequestSchema(method); + if (!schema) { + throw new TypeError( + `'${method}' is not a spec request method; pass schemas as the second argument to setRequestHandler().` + ); + } + stored = (request, ctx) => Promise.resolve(schemasOrHandler(schema.parse(request), ctx)); + } else if (maybeHandler) { + stored = async (request, ctx) => { + const userParams = { ...request.params }; + delete userParams._meta; + const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for ${method}: ${parsed.error}`); + } + return maybeHandler(parsed.data, ctx); + }; + } else { + throw new TypeError('setRequestHandler: handler is required'); + } + + this._requestHandlers.set(method, this._wrapHandler(method, stored)); + } + + /** + * Hook for subclasses to wrap a registered request handler with role-specific + * validation or behavior (e.g. `Server` validates `tools/call` results, `Client` + * validates `elicitation/create` mode and result). Runs for both the 2-arg and + * 3-arg registration paths. The default implementation is identity. + * + * Subclasses overriding this hook avoid redeclaring `setRequestHandler`'s overload set. + */ + protected _wrapHandler( + _method: string, + handler: (request: JSONRPCRequest, ctx: ContextT) => Promise + ): (request: JSONRPCRequest, ctx: ContextT) => Promise { + return handler; + } + + /** + * Removes the request handler for the given method. + */ + removeRequestHandler(method: RequestMethod | string): void { + this._requestHandlers.delete(method); + } + + /** + * Asserts that a request handler has not already been set for the given method, in preparation for a new one being automatically installed. + */ + assertCanSetRequestHandler(method: RequestMethod | string): void { + if (this._requestHandlers.has(method)) { + throw new Error(`A request handler for ${method} already exists, which would be overridden`); + } + } + + /** + * Registers a handler to invoke when this protocol object receives a notification with the given method. + * + * Note that this will replace any previous notification handler for the same method. + * + * For spec methods, pass `(method, handler)`; the notification is parsed with the + * spec schema. For custom (non-spec) methods, pass `(method, schemas, handler)`; + * `params` are validated against `schemas.params` and the handler receives the + * parsed params object directly. The raw notification is passed as the second + * argument; `_meta` is recoverable via `notification.params?._meta`. + */ + setNotificationHandler( + method: M, + handler: (notification: NotificationTypeMap[M]) => void | Promise + ): void; + setNotificationHandler

( + method: string, + schemas: { params: P }, + handler: (params: StandardSchemaV1.InferOutput

, notification: Notification) => void | Promise + ): void; + setNotificationHandler( + method: string, + schemasOrHandler: { params: StandardSchemaV1 } | ((notification: unknown) => void | Promise), + maybeHandler?: (params: unknown, notification: Notification) => void | Promise + ): void { + if (typeof schemasOrHandler === 'function') { + const schema = getNotificationSchema(method); + if (!schema) { + throw new TypeError( + `'${method}' is not a spec notification method; pass schemas as the second argument to setNotificationHandler().` + ); + } + this._notificationHandlers.set(method, notification => Promise.resolve(schemasOrHandler(schema.parse(notification)))); + return; + } + + if (!maybeHandler) { + throw new TypeError('setNotificationHandler: handler is required'); + } + this._notificationHandlers.set(method, async notification => { + const userParams = { ...notification.params }; + delete userParams._meta; + const parsed = await validateStandardSchema(schemasOrHandler.params, userParams); + if (!parsed.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid params for notification ${method}: ${parsed.error}`); + } + await maybeHandler(parsed.data, notification); + }); + } + + /** + * Removes the notification handler for the given method. + */ + removeNotificationHandler(method: NotificationMethod | string): void { + this._notificationHandlers.delete(method); + } +} + +/** + * Schema bundle accepted by {@linkcode Protocol.setRequestHandler | setRequestHandler}'s 3-arg form. + * + * `params` is required and validates the inbound `request.params`. `result` is optional; + * when supplied it types the handler's return value (no runtime validation is performed + * on the result). + */ +export interface RequestHandlerSchemas< + P extends StandardSchemaV1 = StandardSchemaV1, + R extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined +> { + params: P; + result?: R; +} + +type InferHandlerResult = R extends StandardSchemaV1 ? StandardSchemaV1.InferOutput : Result; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function mergeCapabilities(base: ServerCapabilities, additional: Partial): ServerCapabilities; +export function mergeCapabilities(base: ClientCapabilities, additional: Partial): ClientCapabilities; +export function mergeCapabilities(base: T, additional: Partial): T { + const result: T = { ...base }; + for (const key in additional) { + const k = key as keyof T; + const addValue = additional[k]; + if (addValue === undefined) continue; + const baseValue = result[k]; + result[k] = + isPlainObject(baseValue) && isPlainObject(addValue) + ? ({ ...(baseValue as Record), ...(addValue as Record) } as T[typeof k]) + : (addValue as T[typeof k]); + } + return result; +} diff --git a/packages/core/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts new file mode 100644 index 0000000..25922a3 --- /dev/null +++ b/packages/core/src/shared/responseMessage.ts @@ -0,0 +1,98 @@ +import type { Result, Task } from '../types/index.js'; + +/** + * Base message type for the response stream. + */ +export interface BaseResponseMessage { + type: string; +} + +/** + * Task status update message. + * + * Yielded on each poll iteration while the task is active (e.g. while + * `working`). May be emitted multiple times with the same status. + */ +export interface TaskStatusMessage extends BaseResponseMessage { + type: 'taskStatus'; + task: Task; +} + +/** + * Task created message. + * + * Yielded once when the server creates a new task for a long-running operation. + * This is always the first message for task-augmented requests. + */ +export interface TaskCreatedMessage extends BaseResponseMessage { + type: 'taskCreated'; + task: Task; +} + +/** + * Final result message. + * + * Yielded once when the operation completes successfully. Terminal — no further + * messages will follow. + */ +export interface ResultMessage extends BaseResponseMessage { + type: 'result'; + result: T; +} + +/** + * Error message. + * + * Yielded once if the operation fails. Terminal — no further messages will follow. + */ +export interface ErrorMessage extends BaseResponseMessage { + type: 'error'; + error: Error; +} + +/** + * Union of all message types yielded by task-aware streaming APIs such as + * {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#callToolStream | callToolStream()}, + * {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#requestStream | ExperimentalClientTasks.requestStream()}, and + * {@linkcode @modelcontextprotocol/server!experimental/tasks/server.ExperimentalServerTasks#requestStream | ExperimentalServerTasks.requestStream()}. + * + * A typical sequence is: + * 1. `taskCreated` — task is registered (once) + * 2. `taskStatus` — zero or more progress updates + * 3. `result` **or** `error` — terminal message (once) + * + * Progress notifications are handled through the existing {@linkcode index.RequestOptions | onprogress} callback. + * Side-channeled messages (server requests/notifications) are handled through registered handlers. + */ +export type ResponseMessage = TaskStatusMessage | TaskCreatedMessage | ResultMessage | ErrorMessage; + +export type AsyncGeneratorValue = T extends AsyncGenerator ? U : never; + +/** + * Collects all values from an async generator into an array. + */ +export async function toArrayAsync>(it: T): Promise[]> { + const arr: AsyncGeneratorValue[] = []; + for await (const o of it) { + arr.push(o as AsyncGeneratorValue); + } + + return arr; +} + +/** + * Consumes a {@linkcode ResponseMessage} stream and returns the final result, + * discarding intermediate `taskCreated` and `taskStatus` messages. Throws + * if an `error` message is received or the stream ends without a result. + */ +export async function takeResult>>(it: U): Promise { + for await (const o of it) { + if (o.type === 'result') { + return o.result; + } else if (o.type === 'error') { + throw o.error; + } + } + + throw new Error('No result in stream.'); +} diff --git a/packages/core/src/shared/stdio.ts b/packages/core/src/shared/stdio.ts new file mode 100644 index 0000000..7283a5e --- /dev/null +++ b/packages/core/src/shared/stdio.ts @@ -0,0 +1,50 @@ +import type { JSONRPCMessage } from '../types/index.js'; +import { JSONRPCMessageSchema } from '../types/index.js'; + +/** + * Buffers a continuous stdio stream into discrete JSON-RPC messages. + */ +export class ReadBuffer { + private _buffer?: Buffer; + + append(chunk: Buffer): void { + this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk; + } + + readMessage(): JSONRPCMessage | null { + while (this._buffer) { + const index = this._buffer.indexOf('\n'); + if (index === -1) { + return null; + } + + const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, ''); + this._buffer = this._buffer.subarray(index + 1); + + try { + return deserializeMessage(line); + } catch (error) { + // Skip non-JSON lines (e.g., debug output from hot-reload tools like + // tsx or nodemon that write to stdout). Schema validation errors still + // throw so malformed-but-valid-JSON messages surface via onerror. + if (error instanceof SyntaxError) { + continue; + } + throw error; + } + } + return null; + } + + clear(): void { + this._buffer = undefined; + } +} + +export function deserializeMessage(line: string): JSONRPCMessage { + return JSONRPCMessageSchema.parse(JSON.parse(line)); +} + +export function serializeMessage(message: JSONRPCMessage): string { + return JSON.stringify(message) + '\n'; +} diff --git a/packages/core/src/shared/taskManager.ts b/packages/core/src/shared/taskManager.ts new file mode 100644 index 0000000..257dbec --- /dev/null +++ b/packages/core/src/shared/taskManager.ts @@ -0,0 +1,915 @@ +import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; +import { isTerminal } from '../experimental/tasks/interfaces.js'; +import type { + GetTaskPayloadRequest, + GetTaskRequest, + GetTaskResult, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, + Notification, + Request, + RequestId, + Result, + Task, + TaskCreationParams, + TaskStatusNotification +} from '../types/index.js'; +import { + CancelTaskResultSchema, + CreateTaskResultSchema, + GetTaskResultSchema, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + isTaskAugmentedRequestParams, + ListTasksResultSchema, + ProtocolError, + ProtocolErrorCode, + RELATED_TASK_META_KEY, + TaskStatusNotificationSchema +} from '../types/index.js'; +import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js'; +import type { StandardSchemaV1 } from '../util/standardSchema.js'; +import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js'; +import type { ResponseMessage } from './responseMessage.js'; + +/** + * Host interface for TaskManager to call back into Protocol. @internal + */ +export interface TaskManagerHost { + request( + request: Request, + resultSchema: T, + options?: RequestOptions + ): Promise>; + notification(notification: Notification, options?: NotificationOptions): Promise; + reportError(error: Error): void; + removeProgressHandler(token: number): void; + registerHandler(method: string, handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise): void; + sendOnResponseStream(message: JSONRPCNotification | JSONRPCRequest, relatedRequestId: RequestId): Promise; + enforceStrictCapabilities: boolean; + assertTaskCapability(method: string): void; + assertTaskHandlerCapability(method: string): void; +} + +/** + * Context provided to TaskManager when processing an inbound request. + * @internal + */ +export interface InboundContext { + sessionId?: string; + sendNotification: (notification: Notification, options?: NotificationOptions) => Promise; + sendRequest: ( + request: Request, + resultSchema: U, + options?: RequestOptions + ) => Promise>; +} + +/** + * Result returned by TaskManager after processing an inbound request. + * @internal + */ +export interface InboundResult { + taskContext?: BaseContext['task']; + sendNotification: (notification: Notification) => Promise; + sendRequest: ( + request: Request, + resultSchema: U, + options?: Omit + ) => Promise>; + routeResponse: (message: JSONRPCResponse | JSONRPCErrorResponse) => Promise; + hasTaskCreationParams: boolean; + /** + * Optional validation to run inside the async handler chain (before the request handler). + * Throwing here produces a proper JSON-RPC error response, matching the behavior of + * capability checks on main. + */ + validateInbound?: () => void; +} + +/** + * Options that can be given per request. + */ +// relatedTask is excluded as the SDK controls if this is sent according to if the source is a task. +export type TaskRequestOptions = Omit; + +/** + * Request-scoped TaskStore interface. + */ +export interface RequestTaskStore { + /** + * Creates a new task with the given creation parameters. + * The implementation generates a unique taskId and createdAt timestamp. + * + * @param taskParams - The task creation parameters from the request + * @returns The created task object + */ + createTask(taskParams: CreateTaskOptions): Promise; + + /** + * Gets the current status of a task. + * + * @param taskId - The task identifier + * @returns The task object + * @throws If the task does not exist + */ + getTask(taskId: string): Promise; + + /** + * Stores the result of a task and sets its final status. + * + * @param taskId - The task identifier + * @param status - The final status: 'completed' for success, 'failed' for errors + * @param result - The result to store + */ + storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result): Promise; + + /** + * Retrieves the stored result of a task. + * + * @param taskId - The task identifier + * @returns The stored result + */ + getTaskResult(taskId: string): Promise; + + /** + * Updates a task's status (e.g., to 'cancelled', 'failed', 'completed'). + * + * @param taskId - The task identifier + * @param status - The new status + * @param statusMessage - Optional diagnostic message for failed tasks or other status information + */ + updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string): Promise; + + /** + * Lists tasks, optionally starting from a pagination cursor. + * + * @param cursor - Optional cursor for pagination + * @returns An object containing the tasks array and an optional nextCursor + */ + listTasks(cursor?: string): Promise<{ tasks: Task[]; nextCursor?: string }>; +} + +/** + * Task context provided to request handlers when task storage is configured. + */ +export type TaskContext = { + id?: string; + store: RequestTaskStore; + requestedTtl?: number; +}; + +export type TaskManagerOptions = { + /** + * Task storage implementation. Required for handling incoming task requests (server-side). + * Not required for sending task requests (client-side outbound API). + */ + taskStore?: TaskStore; + /** + * Optional task message queue implementation for managing server-initiated messages + * that will be delivered through the tasks/result response stream. + */ + taskMessageQueue?: TaskMessageQueue; + /** + * Default polling interval (in milliseconds) for task status checks when no pollInterval + * is provided by the server. Defaults to 1000ms if not specified. + */ + defaultTaskPollInterval?: number; + /** + * Maximum number of messages that can be queued per task for side-channel delivery. + * If undefined, the queue size is unbounded. + */ + maxTaskQueueSize?: number; +}; + +/** + * Extracts {@linkcode TaskManagerOptions} from a capability object that mixes in runtime fields. + * Returns `undefined` when no task capability is configured. + */ +export function extractTaskManagerOptions(tasksCapability: TaskManagerOptions | undefined): TaskManagerOptions | undefined { + if (!tasksCapability) return undefined; + const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize } = tasksCapability; + return { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize }; +} + +/** + * Manages task orchestration: state, message queuing, and polling. + * Capability checking is delegated to the Protocol host. + * @internal + */ +export class TaskManager { + private _taskStore?: TaskStore; + private _taskMessageQueue?: TaskMessageQueue; + private _taskProgressTokens: Map = new Map(); + private _requestResolvers: Map void> = new Map(); + private _options: TaskManagerOptions; + private _host?: TaskManagerHost; + + constructor(options: TaskManagerOptions) { + this._options = options; + this._taskStore = options.taskStore; + this._taskMessageQueue = options.taskMessageQueue; + } + + bind(host: TaskManagerHost): void { + this._host = host; + + if (this._taskStore) { + host.registerHandler('tasks/get', async (request, ctx) => { + const params = request.params as { taskId: string }; + const task = await this.handleGetTask(params.taskId, ctx.sessionId); + // Per spec: tasks/get responses SHALL NOT include related-task metadata + // as the taskId parameter is the source of truth + return { + ...task + } as Result; + }); + + host.registerHandler('tasks/result', async (request, ctx) => { + const params = request.params as { taskId: string }; + return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => { + // Send the message on the response stream by passing the relatedRequestId + // This tells the transport to write the message to the tasks/result response stream + await host.sendOnResponseStream(message, ctx.mcpReq.id); + }); + }); + + host.registerHandler('tasks/list', async (request, ctx) => { + const params = request.params as { cursor?: string } | undefined; + return (await this.handleListTasks(params?.cursor, ctx.sessionId)) as Result; + }); + + host.registerHandler('tasks/cancel', async (request, ctx) => { + const params = request.params as { taskId: string }; + return await this.handleCancelTask(params.taskId, ctx.sessionId); + }); + } + } + + protected get _requireHost(): TaskManagerHost { + if (!this._host) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not bound to a Protocol host — call bind() first'); + } + return this._host; + } + + get taskStore(): TaskStore | undefined { + return this._taskStore; + } + + private get _requireTaskStore(): TaskStore { + if (!this._taskStore) { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskStore is not configured'); + } + return this._taskStore; + } + + get taskMessageQueue(): TaskMessageQueue | undefined { + return this._taskMessageQueue; + } + + // -- Public API (client-facing) -- + async *requestStream( + request: Request, + resultSchema: T, + options?: RequestOptions + ): AsyncGenerator>, void, void> { + const host = this._requireHost; + const { task } = options ?? {}; + + if (!task) { + try { + // TODO: SchemaOutput (Zod) and StandardSchemaV1.InferOutput (host.request's return) + // resolve to the same type for Zod schemas, but TS can't unify them generically. + // Removing this cast requires aligning ResponseMessage with StandardSchema. + const result = (await host.request(request, resultSchema, options)) as SchemaOutput; + yield { type: 'result', result }; + } catch (error) { + yield { + type: 'error', + error: error instanceof Error ? error : new Error(String(error)) + }; + } + return; + } + + let taskId: string | undefined; + try { + const createResult = await host.request(request, CreateTaskResultSchema, options); + + if (createResult.task) { + taskId = createResult.task.taskId; + yield { type: 'taskCreated', task: createResult.task }; + } else { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'Task creation did not return a task'); + } + + while (true) { + const task = await this.getTask({ taskId }, options); + yield { type: 'taskStatus', task }; + + if (isTerminal(task.status)) { + switch (task.status) { + case 'completed': + case 'failed': { + const result = await this.getTaskResult({ taskId }, resultSchema, options); + yield { type: 'result', result }; + break; + } + case 'cancelled': { + yield { + type: 'error', + error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} was cancelled`) + }; + break; + } + } + return; + } + + if (task.status === 'input_required') { + const result = await this.getTaskResult({ taskId }, resultSchema, options); + yield { type: 'result', result }; + return; + } + + const pollInterval = task.pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; + await new Promise(resolve => setTimeout(resolve, pollInterval)); + options?.signal?.throwIfAborted(); + } + } catch (error) { + yield { + type: 'error', + error: error instanceof Error ? error : new Error(String(error)) + }; + } + } + + async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { + return this._requireHost.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); + } + + async getTaskResult( + params: GetTaskPayloadRequest['params'], + resultSchema: T, + options?: RequestOptions + ): Promise> { + // TODO: same SchemaOutput vs StandardSchemaV1.InferOutput mismatch as requestStream above. + return this._requireHost.request({ method: 'tasks/result', params }, resultSchema, options) as Promise>; + } + + async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { + return this._requireHost.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); + } + + async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { + return this._requireHost.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); + } + + // -- Handler bodies (delegated from Protocol's registered handlers) -- + + private async handleGetTask(taskId: string, sessionId?: string): Promise { + const task = await this._requireTaskStore.getTask(taskId, sessionId); + if (!task) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); + } + return task; + } + + private async handleGetTaskPayload( + taskId: string, + sessionId: string | undefined, + signal: AbortSignal, + sendOnResponseStream: (message: JSONRPCNotification | JSONRPCRequest) => Promise + ): Promise { + const handleTaskResult = async (): Promise => { + if (this._taskMessageQueue) { + let queuedMessage: QueuedMessage | undefined; + while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, sessionId))) { + if (queuedMessage.type === 'response' || queuedMessage.type === 'error') { + const message = queuedMessage.message; + const requestId = message.id; + const resolver = this._requestResolvers.get(requestId as RequestId); + + if (resolver) { + this._requestResolvers.delete(requestId as RequestId); + if (queuedMessage.type === 'response') { + resolver(message as JSONRPCResultResponse); + } else { + const errorMessage = message as JSONRPCErrorResponse; + resolver(new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data)); + } + } else { + const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; + this._host?.reportError(new Error(`${messageType} handler missing for request ${requestId}`)); + } + continue; + } + + await sendOnResponseStream(queuedMessage.message as JSONRPCNotification | JSONRPCRequest); + } + } + + const task = await this._requireTaskStore.getTask(taskId, sessionId); + if (!task) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); + } + + if (!isTerminal(task.status)) { + await this._waitForTaskUpdate(task.pollInterval, signal); + return await handleTaskResult(); + } + + const result = await this._requireTaskStore.getTaskResult(taskId, sessionId); + await this._clearTaskQueue(taskId); + + return { + ...result, + _meta: { + ...result._meta, + [RELATED_TASK_META_KEY]: { taskId } + } + }; + }; + + return await handleTaskResult(); + } + + private async handleListTasks( + cursor: string | undefined, + sessionId?: string + ): Promise<{ tasks: Task[]; nextCursor?: string; _meta: Record }> { + try { + const { tasks, nextCursor } = await this._requireTaskStore.listTasks(cursor, sessionId); + return { tasks, nextCursor, _meta: {} }; + } catch (error) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Failed to list tasks: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private async handleCancelTask(taskId: string, sessionId?: string): Promise { + try { + const task = await this._requireTaskStore.getTask(taskId, sessionId); + if (!task) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`); + } + + if (isTerminal(task.status)) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); + } + + await this._requireTaskStore.updateTaskStatus(taskId, 'cancelled', 'Client cancelled task execution.', sessionId); + await this._clearTaskQueue(taskId); + + const cancelledTask = await this._requireTaskStore.getTask(taskId, sessionId); + if (!cancelledTask) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found after cancellation: ${taskId}`); + } + + return { _meta: {}, ...cancelledTask }; + } catch (error) { + if (error instanceof ProtocolError) throw error; + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + `Failed to cancel task: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + // -- Internal delegation methods -- + + private prepareOutboundRequest( + jsonrpcRequest: JSONRPCRequest, + options: RequestOptions | undefined, + messageId: number, + responseHandler: (response: JSONRPCResultResponse | Error) => void, + onError: (error: unknown) => void + ): boolean { + const { task, relatedTask } = options ?? {}; + + if (task) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + task: task + }; + } + + if (relatedTask) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + _meta: { + ...jsonrpcRequest.params?._meta, + [RELATED_TASK_META_KEY]: relatedTask + } + }; + } + + const relatedTaskId = relatedTask?.taskId; + if (relatedTaskId) { + this._requestResolvers.set(messageId, responseHandler); + + this._enqueueTaskMessage(relatedTaskId, { + type: 'request', + message: jsonrpcRequest, + timestamp: Date.now() + }).catch(error => { + onError(error); + }); + + return true; + } + + return false; + } + + private extractInboundTaskContext( + request: JSONRPCRequest, + sessionId?: string + ): { + relatedTaskId?: string; + taskCreationParams?: TaskCreationParams; + taskContext?: TaskContext; + } { + const relatedTaskId = (request.params?._meta as Record | undefined)?.[RELATED_TASK_META_KEY]?.taskId; + const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; + + // Provide task context whenever a task store is configured, + // not just for task-related requests — tools need ctx.task.store + let taskContext: TaskContext | undefined; + if (this._taskStore) { + const store = this.createRequestTaskStore(request, sessionId); + taskContext = { + id: relatedTaskId, + store, + requestedTtl: taskCreationParams?.ttl + }; + } + + if (!relatedTaskId && !taskCreationParams && !taskContext) { + return {}; + } + + return { + relatedTaskId, + taskCreationParams, + taskContext + }; + } + + private wrapSendNotification( + relatedTaskId: string, + originalSendNotification: (notification: Notification, options?: NotificationOptions) => Promise + ): (notification: Notification) => Promise { + return async (notification: Notification) => { + const notificationOptions: NotificationOptions = { relatedTask: { taskId: relatedTaskId } }; + await originalSendNotification(notification, notificationOptions); + }; + } + + private wrapSendRequest( + relatedTaskId: string, + taskStore: RequestTaskStore | undefined, + originalSendRequest: ( + request: Request, + resultSchema: V, + options?: RequestOptions + ) => Promise> + ): ( + request: Request, + resultSchema: V, + options?: TaskRequestOptions + ) => Promise> { + return async (request: Request, resultSchema: V, options?: TaskRequestOptions) => { + const requestOptions: RequestOptions = { ...options }; + if (relatedTaskId && !requestOptions.relatedTask) { + requestOptions.relatedTask = { taskId: relatedTaskId }; + } + + const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; + if (effectiveTaskId && taskStore) { + await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); + } + + return await originalSendRequest(request, resultSchema, requestOptions); + }; + } + + private handleResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean { + const messageId = Number(response.id); + const resolver = this._requestResolvers.get(messageId); + if (resolver) { + this._requestResolvers.delete(messageId); + if (isJSONRPCResultResponse(response)) { + resolver(response); + } else { + resolver(new ProtocolError(response.error.code, response.error.message, response.error.data)); + } + return true; + } + return false; + } + + private shouldPreserveProgressHandler(response: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): boolean { + if (isJSONRPCResultResponse(response) && response.result && typeof response.result === 'object') { + const result = response.result as Record; + if (result.task && typeof result.task === 'object') { + const task = result.task as Record; + if (typeof task.taskId === 'string') { + this._taskProgressTokens.set(task.taskId, messageId); + return true; + } + } + } + return false; + } + + private async routeNotification(notification: Notification, options?: NotificationOptions): Promise { + const relatedTaskId = options?.relatedTask?.taskId; + if (!relatedTaskId) return false; + + const jsonrpcNotification: JSONRPCNotification = { + ...notification, + jsonrpc: '2.0', + params: { + ...notification.params, + _meta: { + ...notification.params?._meta, + [RELATED_TASK_META_KEY]: options!.relatedTask + } + } + }; + + await this._enqueueTaskMessage(relatedTaskId, { + type: 'notification', + message: jsonrpcNotification, + timestamp: Date.now() + }); + + return true; + } + + private async routeResponse( + relatedTaskId: string | undefined, + message: JSONRPCResponse | JSONRPCErrorResponse, + sessionId?: string + ): Promise { + if (!relatedTaskId || !this._taskMessageQueue) return false; + + await (isJSONRPCErrorResponse(message) + ? this._enqueueTaskMessage(relatedTaskId, { type: 'error', message, timestamp: Date.now() }, sessionId) + : this._enqueueTaskMessage( + relatedTaskId, + { type: 'response', message: message as JSONRPCResultResponse, timestamp: Date.now() }, + sessionId + )); + return true; + } + + private createRequestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { + const taskStore = this._requireTaskStore; + const host = this._host; + + return { + createTask: async taskParams => { + if (!request) throw new Error('No request provided'); + return await taskStore.createTask(taskParams, request.id, { method: request.method, params: request.params }, sessionId); + }, + getTask: async taskId => { + const task = await taskStore.getTask(taskId, sessionId); + if (!task) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); + return task; + }, + storeTaskResult: async (taskId, status, result) => { + await taskStore.storeTaskResult(taskId, status, result, sessionId); + const task = await taskStore.getTask(taskId, sessionId); + if (task) { + const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ + method: 'notifications/tasks/status', + params: task + }); + await host?.notification(notification as Notification); + if (isTerminal(task.status)) { + this._cleanupTaskProgressHandler(taskId); + } + } + }, + getTaskResult: taskId => taskStore.getTaskResult(taskId, sessionId), + updateTaskStatus: async (taskId, status, statusMessage) => { + const task = await taskStore.getTask(taskId, sessionId); + if (!task) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); + } + if (isTerminal(task.status)) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.` + ); + } + await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId); + const updatedTask = await taskStore.getTask(taskId, sessionId); + if (updatedTask) { + const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ + method: 'notifications/tasks/status', + params: updatedTask + }); + await host?.notification(notification as Notification); + if (isTerminal(updatedTask.status)) { + this._cleanupTaskProgressHandler(taskId); + } + } + }, + listTasks: cursor => taskStore.listTasks(cursor, sessionId) + }; + } + + // -- Lifecycle methods (called by Protocol directly) -- + + processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { + const taskInfo = this.extractInboundTaskContext(request, ctx.sessionId); + const relatedTaskId = taskInfo?.relatedTaskId; + + const sendNotification = relatedTaskId + ? this.wrapSendNotification(relatedTaskId, ctx.sendNotification) + : (notification: Notification) => ctx.sendNotification(notification); + + const sendRequest = relatedTaskId + ? this.wrapSendRequest(relatedTaskId, taskInfo?.taskContext?.store, ctx.sendRequest) + : taskInfo?.taskContext + ? this.wrapSendRequest('', taskInfo.taskContext.store, ctx.sendRequest) + : ctx.sendRequest; + + const hasTaskCreationParams = !!taskInfo?.taskCreationParams; + + return { + taskContext: taskInfo?.taskContext, + sendNotification, + sendRequest, + routeResponse: async (message: JSONRPCResponse | JSONRPCErrorResponse) => { + if (relatedTaskId) { + return this.routeResponse(relatedTaskId, message, ctx.sessionId); + } + return false; + }, + hasTaskCreationParams, + // Deferred validation: runs inside the async handler chain so errors + // produce proper JSON-RPC error responses (matching main's behavior). + validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined + }; + } + + processOutboundRequest( + jsonrpcRequest: JSONRPCRequest, + options: RequestOptions | undefined, + messageId: number, + responseHandler: (response: JSONRPCResultResponse | Error) => void, + onError: (error: unknown) => void + ): { queued: boolean } { + // Check task capability when sending a task-augmented request (matches main's enforceStrictCapabilities gate) + if (this._requireHost.enforceStrictCapabilities && options?.task) { + this._requireHost.assertTaskCapability(jsonrpcRequest.method); + } + + const queued = this.prepareOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, onError); + return { queued }; + } + + processInboundResponse( + response: JSONRPCResponse | JSONRPCErrorResponse, + messageId: number + ): { consumed: boolean; preserveProgress: boolean } { + const consumed = this.handleResponse(response); + if (consumed) { + return { consumed: true, preserveProgress: false }; + } + const preserveProgress = this.shouldPreserveProgressHandler(response, messageId); + return { consumed: false, preserveProgress }; + } + + async processOutboundNotification( + notification: Notification, + options?: NotificationOptions + ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { + // Try queuing first + const queued = await this.routeNotification(notification, options); + if (queued) return { queued: true }; + + // Build JSONRPC notification with optional relatedTask metadata + let jsonrpcNotification: JSONRPCNotification = { ...notification, jsonrpc: '2.0' }; + if (options?.relatedTask) { + jsonrpcNotification = { + ...jsonrpcNotification, + params: { + ...jsonrpcNotification.params, + _meta: { + ...jsonrpcNotification.params?._meta, + [RELATED_TASK_META_KEY]: options.relatedTask + } + } + }; + } + return { queued: false, jsonrpcNotification }; + } + + onClose(): void { + this._taskProgressTokens.clear(); + this._requestResolvers.clear(); + } + + // -- Private helpers -- + + private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise { + if (!this._taskStore || !this._taskMessageQueue) { + throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured'); + } + await this._taskMessageQueue.enqueue(taskId, message, sessionId, this._options.maxTaskQueueSize); + } + + private async _clearTaskQueue(taskId: string, sessionId?: string): Promise { + if (this._taskMessageQueue) { + const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); + for (const message of messages) { + if (message.type === 'request' && isJSONRPCRequest(message.message)) { + const requestId = message.message.id as RequestId; + const resolver = this._requestResolvers.get(requestId); + if (resolver) { + resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed')); + this._requestResolvers.delete(requestId); + } else { + this._host?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); + } + } + } + } + } + + private async _waitForTaskUpdate(pollInterval: number | undefined, signal: AbortSignal): Promise { + const interval = pollInterval ?? this._options.defaultTaskPollInterval ?? 1000; + + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); + return; + } + const timeoutId = setTimeout(resolve, interval); + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled')); + }, + { once: true } + ); + }); + } + + private _cleanupTaskProgressHandler(taskId: string): void { + const progressToken = this._taskProgressTokens.get(taskId); + if (progressToken !== undefined) { + this._host?.removeProgressHandler(progressToken); + this._taskProgressTokens.delete(taskId); + } + } +} + +/** + * No-op TaskManager used when tasks capability is not configured. + * Provides passthrough implementations for the hot paths, avoiding + * unnecessary task extraction logic on every request. + */ +export class NullTaskManager extends TaskManager { + constructor() { + super({}); + } + + override processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult { + const hasTaskCreationParams = isTaskAugmentedRequestParams(request.params) && !!request.params.task; + return { + taskContext: undefined, + sendNotification: (notification: Notification) => ctx.sendNotification(notification), + sendRequest: ctx.sendRequest, + routeResponse: async () => false, + hasTaskCreationParams, + validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined + }; + } + + // processOutboundRequest is inherited - it handles task/relatedTask augmentation + // and only queues if relatedTask is set (which won't happen without a task store) + + // processInboundResponse is inherited - it checks _requestResolvers (empty for NullTaskManager) + // and _taskProgressTokens (empty for NullTaskManager) + + override async processOutboundNotification( + notification: Notification, + _options?: NotificationOptions + ): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> { + return { queued: false, jsonrpcNotification: { ...notification, jsonrpc: '2.0' } }; + } +} diff --git a/packages/core/src/shared/toolNameValidation.ts b/packages/core/src/shared/toolNameValidation.ts new file mode 100644 index 0000000..41bc449 --- /dev/null +++ b/packages/core/src/shared/toolNameValidation.ts @@ -0,0 +1,116 @@ +/** + * Tool name validation utilities according to SEP: Specify Format for Tool Names + * + * Tool names SHOULD be between 1 and 128 characters in length (inclusive). + * Tool names are case-sensitive. + * Allowed characters: uppercase and lowercase ASCII letters (`A-Z`, `a-z`), digits + * (`0-9`), underscore (`_`), dash (`-`), and dot (`.`). + * Tool names SHOULD NOT contain spaces, commas, or other special characters. + * + * @see {@link https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986 | SEP-986: Specify Format for Tool Names} + */ + +/** + * Regular expression for valid tool names according to SEP-986 specification + */ +const TOOL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; + +/** + * Validates a tool name according to the SEP specification + * @param name - The tool name to validate + * @returns An object containing validation result and any warnings + */ +export function validateToolName(name: string): { + isValid: boolean; + warnings: string[]; +} { + const warnings: string[] = []; + + // Check length + if (name.length === 0) { + return { + isValid: false, + warnings: ['Tool name cannot be empty'] + }; + } + + if (name.length > 128) { + return { + isValid: false, + warnings: [`Tool name exceeds maximum length of 128 characters (current: ${name.length})`] + }; + } + + // Check for specific problematic patterns (these are warnings, not validation failures) + if (name.includes(' ')) { + warnings.push('Tool name contains spaces, which may cause parsing issues'); + } + + if (name.includes(',')) { + warnings.push('Tool name contains commas, which may cause parsing issues'); + } + + // Check for potentially confusing patterns (leading/trailing dashes, dots, slashes) + if (name.startsWith('-') || name.endsWith('-')) { + warnings.push('Tool name starts or ends with a dash, which may cause parsing issues in some contexts'); + } + + if (name.startsWith('.') || name.endsWith('.')) { + warnings.push('Tool name starts or ends with a dot, which may cause parsing issues in some contexts'); + } + + // Check for invalid characters + if (!TOOL_NAME_REGEX.test(name)) { + const invalidChars = [...name] + .filter(char => !/[A-Za-z0-9._-]/.test(char)) + .filter((char, index, arr) => arr.indexOf(char) === index); // Remove duplicates + + warnings.push( + `Tool name contains invalid characters: ${invalidChars.map(c => `"${c}"`).join(', ')}`, + 'Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)' + ); + + return { + isValid: false, + warnings + }; + } + + return { + isValid: true, + warnings + }; +} + +/** + * Issues warnings for non-conforming tool names + * @param name - The tool name that triggered the warnings + * @param warnings - Array of warning messages + */ +export function issueToolNameWarning(name: string, warnings: string[]): void { + if (warnings.length > 0) { + console.warn(`Tool name validation warning for "${name}":`); + for (const warning of warnings) { + console.warn(` - ${warning}`); + } + console.warn('Tool registration will proceed, but this may cause compatibility issues.'); + console.warn('Consider updating the tool name to conform to the MCP tool naming standard.'); + console.warn( + 'See SEP: Specify Format for Tool Names (https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) for more details.' + ); + } +} + +/** + * Validates a tool name and issues warnings for non-conforming names + * @param name - The tool name to validate + * @returns `true` if the name is valid, `false` otherwise + */ +export function validateAndWarnToolName(name: string): boolean { + const result = validateToolName(name); + + // Always issue warnings for any validation issues (both invalid names and warnings) + issueToolNameWarning(name, result.warnings); + + return result.isValid; +} diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts new file mode 100644 index 0000000..c606e2e --- /dev/null +++ b/packages/core/src/shared/transport.ts @@ -0,0 +1,134 @@ +import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; + +export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; + +/** + * Normalizes `HeadersInit` to a plain `Record` for manipulation. + * Handles `Headers` objects, arrays of tuples, and plain objects. + */ +export function normalizeHeaders(headers: RequestInit['headers'] | undefined): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return { ...(headers as Record) }; +} + +/** + * Creates a fetch function that includes base `RequestInit` options. + * This ensures requests inherit settings like credentials, mode, headers, etc. from the base init. + * + * @param baseFetch - The base fetch function to wrap (defaults to global `fetch`) + * @param baseInit - The base `RequestInit` to merge with each request + * @returns A wrapped fetch function that merges base options with call-specific options + */ +export function createFetchWithInit(baseFetch: FetchLike = fetch, baseInit?: RequestInit): FetchLike { + if (!baseInit) { + return baseFetch; + } + + // Return a wrapped fetch that merges base RequestInit with call-specific init + return async (url: string | URL, init?: RequestInit): Promise => { + const mergedInit: RequestInit = { + ...baseInit, + ...init, + // Headers need special handling - merge instead of replace + headers: init?.headers ? { ...normalizeHeaders(baseInit.headers), ...normalizeHeaders(init.headers) } : baseInit.headers + }; + return baseFetch(url, mergedInit); + }; +} + +/** + * Options for sending a JSON-RPC message. + */ +export type TransportSendOptions = { + /** + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + */ + relatedRequestId?: RequestId | undefined; + + /** + * The resumption token used to continue long-running requests that were interrupted. + * + * This allows clients to reconnect and continue from where they left off, if supported by the transport. + */ + resumptionToken?: string | undefined; + + /** + * A callback that is invoked when the resumption token changes, if supported by the transport. + * + * This allows clients to persist the latest token for potential reconnection. + */ + onresumptiontoken?: ((token: string) => void) | undefined; +}; +/** + * Describes the minimal contract for an MCP transport that a client or server can communicate over. + */ +export interface Transport { + /** + * Starts processing messages on the transport, including any connection steps that might need to be taken. + * + * This method should only be called after callbacks are installed, or else messages may be lost. + * + * NOTE: This method should not be called explicitly when using {@linkcode @modelcontextprotocol/client!client/client.Client | Client} or {@linkcode @modelcontextprotocol/server!server/server.Server | Server} classes, as they will implicitly call {@linkcode Transport.start | start()}. + */ + start(): Promise; + + /** + * Sends a JSON-RPC message (request or response). + * + * If present, `relatedRequestId` is used to indicate to the transport which incoming request to associate this outgoing message with. + */ + send(message: JSONRPCMessage, options?: TransportSendOptions): Promise; + + /** + * Closes the connection. + */ + close(): Promise; + + /** + * Callback for when the connection is closed for any reason. + * + * This should be invoked when {@linkcode Transport.close | close()} is called as well. + */ + onclose?: (() => void) | undefined; + + /** + * Callback for when an error occurs. + * + * Note that errors are not necessarily fatal; they are used for reporting any kind of exceptional condition out of band. + */ + onerror?: ((error: Error) => void) | undefined; + + /** + * Callback for when a message (request or response) is received over the connection. + * + * Includes the {@linkcode MessageExtraInfo.request | request} and {@linkcode MessageExtraInfo.authInfo | authInfo} if the transport is authenticated. + * + * The {@linkcode MessageExtraInfo.request | request} can be used to get the original request information (headers, etc.) + */ + onmessage?: ((message: T, extra?: MessageExtraInfo) => void) | undefined; + + /** + * The session ID generated for this connection. + */ + sessionId?: string | undefined; + + /** + * Sets the protocol version used for the connection (called when the initialize response is received). + */ + setProtocolVersion?: ((version: string) => void) | undefined; + + /** + * Sets the supported protocol versions for header validation (called during connect). + * This allows the server to pass its supported versions to the transport. + */ + setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; +} diff --git a/packages/core/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts new file mode 100644 index 0000000..5ffe213 --- /dev/null +++ b/packages/core/src/shared/uriTemplate.ts @@ -0,0 +1,290 @@ +// Claude-authored implementation of RFC 6570 URI Templates + +export type Variables = Record; + +const MAX_TEMPLATE_LENGTH = 1_000_000; // 1MB +const MAX_VARIABLE_LENGTH = 1_000_000; // 1MB +const MAX_TEMPLATE_EXPRESSIONS = 10_000; +const MAX_REGEX_LENGTH = 1_000_000; // 1MB + +export class UriTemplate { + /** + * Returns true if the given string contains any URI template expressions. + * A template expression is a sequence of characters enclosed in curly braces, + * like `{foo}` or `{?bar}`. + */ + static isTemplate(str: string): boolean { + // Look for any sequence of characters between curly braces + // that isn't just whitespace + return /\{[^}\s]+\}/.test(str); + } + + private static validateLength(str: string, max: number, context: string): void { + if (str.length > max) { + throw new Error(`${context} exceeds maximum length of ${max} characters (got ${str.length})`); + } + } + private readonly template: string; + private readonly parts: Array; + + get variableNames(): string[] { + return this.parts.flatMap(part => (typeof part === 'string' ? [] : part.names)); + } + + constructor(template: string) { + UriTemplate.validateLength(template, MAX_TEMPLATE_LENGTH, 'Template'); + this.template = template; + this.parts = this.parse(template); + } + + toString(): string { + return this.template; + } + + private parse(template: string): Array { + const parts: Array = []; + let currentText = ''; + let i = 0; + let expressionCount = 0; + + while (i < template.length) { + if (template[i] === '{') { + if (currentText) { + parts.push(currentText); + currentText = ''; + } + const end = template.indexOf('}', i); + if (end === -1) throw new Error('Unclosed template expression'); + + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new Error(`Template contains too many expressions (max ${MAX_TEMPLATE_EXPRESSIONS})`); + } + + const expr = template.slice(i + 1, end); + const operator = this.getOperator(expr); + const exploded = expr.includes('*'); + const names = this.getNames(expr); + const name = names[0]!; + + // Validate variable name length + for (const name of names) { + UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name'); + } + + parts.push({ name, operator, names, exploded }); + i = end + 1; + } else { + currentText += template[i]; + i++; + } + } + + if (currentText) { + parts.push(currentText); + } + + return parts; + } + + private getOperator(expr: string): string { + const operators = ['+', '#', '.', '/', '?', '&']; + return operators.find(op => expr.startsWith(op)) || ''; + } + + private getNames(expr: string): string[] { + const operator = this.getOperator(expr); + return expr + .slice(operator.length) + .split(',') + .map(name => name.replace('*', '').trim()) + .filter(name => name.length > 0); + } + + private encodeValue(value: string, operator: string): string { + UriTemplate.validateLength(value, MAX_VARIABLE_LENGTH, 'Variable value'); + if (operator === '+' || operator === '#') { + return encodeURI(value); + } + return encodeURIComponent(value); + } + + private expandPart( + part: { + name: string; + operator: string; + names: string[]; + exploded: boolean; + }, + variables: Variables + ): string { + if (part.operator === '?' || part.operator === '&') { + const pairs = part.names + .map(name => { + const value = variables[name]; + if (value === undefined) return ''; + const encoded = Array.isArray(value) + ? value.map(v => this.encodeValue(v, part.operator)).join(',') + : this.encodeValue(value.toString(), part.operator); + return `${name}=${encoded}`; + }) + .filter(pair => pair.length > 0); + + if (pairs.length === 0) return ''; + const separator = part.operator === '?' ? '?' : '&'; + return separator + pairs.join('&'); + } + + if (part.names.length > 1) { + const values = part.names.map(name => variables[name]).filter(v => v !== undefined); + if (values.length === 0) return ''; + return values.map(v => (Array.isArray(v) ? v[0] : v)).join(','); + } + + const value = variables[part.name]; + if (value === undefined) return ''; + + const values = Array.isArray(value) ? value : [value]; + const encoded = values.map(v => this.encodeValue(v, part.operator)); + + switch (part.operator) { + case '': { + return encoded.join(','); + } + case '+': { + return encoded.join(','); + } + case '#': { + return '#' + encoded.join(','); + } + case '.': { + return '.' + encoded.join('.'); + } + case '/': { + return '/' + encoded.join('/'); + } + default: { + return encoded.join(','); + } + } + } + + expand(variables: Variables): string { + let result = ''; + let hasQueryParam = false; + + for (const part of this.parts) { + if (typeof part === 'string') { + result += part; + continue; + } + + const expanded = this.expandPart(part, variables); + if (!expanded) continue; + + // Convert ? to & if we already have a query parameter + result += (part.operator === '?' || part.operator === '&') && hasQueryParam ? expanded.replace('?', '&') : expanded; + + if (part.operator === '?' || part.operator === '&') { + hasQueryParam = true; + } + } + + return result; + } + + private escapeRegExp(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + } + + private partToRegExp(part: { + name: string; + operator: string; + names: string[]; + exploded: boolean; + }): Array<{ pattern: string; name: string }> { + const patterns: Array<{ pattern: string; name: string }> = []; + + // Validate variable name length for matching + for (const name of part.names) { + UriTemplate.validateLength(name, MAX_VARIABLE_LENGTH, 'Variable name'); + } + + if (part.operator === '?' || part.operator === '&') { + for (let i = 0; i < part.names.length; i++) { + const name = part.names[i]!; + const prefix = i === 0 ? '\\' + part.operator : '&'; + patterns.push({ + pattern: prefix + this.escapeRegExp(name) + '=([^&]+)', + name + }); + } + return patterns; + } + + let pattern: string; + const name = part.name; + + switch (part.operator) { + case '': { + pattern = part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'; + break; + } + case '+': + case '#': { + pattern = '(.+)'; + break; + } + case '.': { + pattern = String.raw`\.([^/,]+)`; + break; + } + case '/': { + pattern = '/' + (part.exploded ? '([^/,]+(?:,[^/,]+)*)' : '([^/,]+)'); + break; + } + default: { + pattern = '([^/]+)'; + } + } + + patterns.push({ pattern, name }); + return patterns; + } + + match(uri: string): Variables | null { + UriTemplate.validateLength(uri, MAX_TEMPLATE_LENGTH, 'URI'); + let pattern = '^'; + const names: Array<{ name: string; exploded: boolean }> = []; + + for (const part of this.parts) { + if (typeof part === 'string') { + pattern += this.escapeRegExp(part); + } else { + const patterns = this.partToRegExp(part); + for (const { pattern: partPattern, name } of patterns) { + pattern += partPattern; + names.push({ name, exploded: part.exploded }); + } + } + } + + pattern += '$'; + UriTemplate.validateLength(pattern, MAX_REGEX_LENGTH, 'Generated regex pattern'); + const regex = new RegExp(pattern); + const match = uri.match(regex); + + if (!match) return null; + + const result: Variables = {}; + for (const [i, name_] of names.entries()) { + const { name, exploded } = name_!; + const value = match[i + 1]!; + const cleanName = name.replace('*', ''); + + result[cleanName] = exploded && value.includes(',') ? value.split(',') : value; + } + + return result; + } +} diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts new file mode 100644 index 0000000..878d511 --- /dev/null +++ b/packages/core/src/types/constants.ts @@ -0,0 +1,15 @@ +export const LATEST_PROTOCOL_VERSION = '2025-11-25'; +export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; +export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + +export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; + +/* JSON-RPC types */ +export const JSONRPC_VERSION = '2.0'; + +/* Standard JSON-RPC error code constants */ +export const PARSE_ERROR = -32_700; +export const INVALID_REQUEST = -32_600; +export const METHOD_NOT_FOUND = -32_601; +export const INVALID_PARAMS = -32_602; +export const INTERNAL_ERROR = -32_603; diff --git a/packages/core/src/types/enums.ts b/packages/core/src/types/enums.ts new file mode 100644 index 0000000..0d80242 --- /dev/null +++ b/packages/core/src/types/enums.ts @@ -0,0 +1,16 @@ +/** + * Error codes for protocol errors that cross the wire as JSON-RPC error responses. + * These follow the JSON-RPC specification and MCP-specific extensions. + */ +export enum ProtocolErrorCode { + // Standard JSON-RPC error codes + ParseError = -32_700, + InvalidRequest = -32_600, + MethodNotFound = -32_601, + InvalidParams = -32_602, + InternalError = -32_603, + + // MCP-specific error codes + ResourceNotFound = -32_002, + UrlElicitationRequired = -32_042 +} diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts new file mode 100644 index 0000000..796c0d2 --- /dev/null +++ b/packages/core/src/types/errors.ts @@ -0,0 +1,49 @@ +import { ProtocolErrorCode } from './enums.js'; +import type { ElicitRequestURLParams } from './types.js'; + +/** + * Protocol errors are JSON-RPC errors that cross the wire as error responses. + * They use numeric error codes from the {@linkcode ProtocolErrorCode} enum. + */ +export class ProtocolError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown + ) { + super(message); + this.name = 'ProtocolError'; + } + + /** + * Factory method to create the appropriate error type based on the error code and data + */ + static fromError(code: number, message: string, data?: unknown): ProtocolError { + // Check for specific error types + if (code === ProtocolErrorCode.UrlElicitationRequired && data) { + const errorData = data as { elicitations?: unknown[] }; + if (errorData.elicitations) { + return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message); + } + } + + // Default to generic ProtocolError + return new ProtocolError(code, message, data); + } +} + +/** + * Specialized error type when a tool requires a URL mode elicitation. + * This makes it nicer for the client to handle since there is specific data to work with instead of just a code to check against. + */ +export class UrlElicitationRequiredError extends ProtocolError { + constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) { + super(ProtocolErrorCode.UrlElicitationRequired, message, { + elicitations: elicitations + }); + } + + get elicitations(): ElicitRequestURLParams[] { + return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? []; + } +} diff --git a/packages/core/src/types/guards.ts b/packages/core/src/types/guards.ts new file mode 100644 index 0000000..f385b91 --- /dev/null +++ b/packages/core/src/types/guards.ts @@ -0,0 +1,110 @@ +import { + CallToolResultSchema, + InitializedNotificationSchema, + InitializeRequestSchema, + JSONRPCErrorResponseSchema, + JSONRPCMessageSchema, + JSONRPCNotificationSchema, + JSONRPCRequestSchema, + JSONRPCResponseSchema, + JSONRPCResultResponseSchema, + TaskAugmentedRequestParamsSchema +} from './schemas.js'; +import type { + CallToolResult, + CompleteRequest, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, + InitializedNotification, + InitializeRequest, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, + TaskAugmentedRequestParams +} from './types.js'; + +/** + * Validates and parses an unknown value as a JSON-RPC message. + * + * Use this to validate incoming messages in custom transport implementations. + * Throws if the value does not conform to the JSON-RPC message schema. + * + * @param value - The value to validate (typically a parsed JSON object). + * @returns The validated {@linkcode JSONRPCMessage}. + * @throws If validation fails. + */ +export function parseJSONRPCMessage(value: unknown): JSONRPCMessage { + return JSONRPCMessageSchema.parse(value); +} + +export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => JSONRPCRequestSchema.safeParse(value).success; + +export const isJSONRPCNotification = (value: unknown): value is JSONRPCNotification => JSONRPCNotificationSchema.safeParse(value).success; + +/** + * Checks if a value is a valid {@linkcode JSONRPCResultResponse}. + * @param value - The value to check. + * + * @returns True if the value is a valid {@linkcode JSONRPCResultResponse}, false otherwise. + */ +export const isJSONRPCResultResponse = (value: unknown): value is JSONRPCResultResponse => + JSONRPCResultResponseSchema.safeParse(value).success; + +/** + * Checks if a value is a valid {@linkcode JSONRPCErrorResponse}. + * @param value - The value to check. + * + * @returns True if the value is a valid {@linkcode JSONRPCErrorResponse}, false otherwise. + */ +export const isJSONRPCErrorResponse = (value: unknown): value is JSONRPCErrorResponse => + JSONRPCErrorResponseSchema.safeParse(value).success; + +/** + * Checks if a value is a valid {@linkcode JSONRPCResponse} (either a result or error response). + * @param value - The value to check. + * + * @returns True if the value is a valid {@linkcode JSONRPCResponse}, false otherwise. + */ +export const isJSONRPCResponse = (value: unknown): value is JSONRPCResponse => JSONRPCResponseSchema.safeParse(value).success; + +/** + * Checks if a value is a valid {@linkcode CallToolResult}. + * @param value - The value to check. + * + * @returns True if the value is a valid {@linkcode CallToolResult}, false otherwise. + */ +export const isCallToolResult = (value: unknown): value is CallToolResult => { + if (typeof value !== 'object' || value === null || !('content' in value)) return false; + return CallToolResultSchema.safeParse(value).success; +}; + +/** + * Checks if a value is a valid {@linkcode TaskAugmentedRequestParams}. + * @param value - The value to check. + * + * @returns True if the value is a valid {@linkcode TaskAugmentedRequestParams}, false otherwise. + */ +export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => + TaskAugmentedRequestParamsSchema.safeParse(value).success; + +export const isInitializeRequest = (value: unknown): value is InitializeRequest => InitializeRequestSchema.safeParse(value).success; + +export const isInitializedNotification = (value: unknown): value is InitializedNotification => + InitializedNotificationSchema.safeParse(value).success; + +export function assertCompleteRequestPrompt(request: CompleteRequest): asserts request is CompleteRequestPrompt { + if (request.params.ref.type !== 'ref/prompt') { + throw new TypeError(`Expected CompleteRequestPrompt, but got ${request.params.ref.type}`); + } + void (request as CompleteRequestPrompt); +} + +export function assertCompleteRequestResourceTemplate(request: CompleteRequest): asserts request is CompleteRequestResourceTemplate { + if (request.params.ref.type !== 'ref/resource') { + throw new TypeError(`Expected CompleteRequestResourceTemplate, but got ${request.params.ref.type}`); + } + void (request as CompleteRequestResourceTemplate); +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts new file mode 100644 index 0000000..c150aea --- /dev/null +++ b/packages/core/src/types/index.ts @@ -0,0 +1,9 @@ +// Internal barrel — re-exports everything for use within the SDK packages. +// The public API is defined in @modelcontextprotocol/core/public (see exports/public/index.ts). +export * from './constants.js'; +export * from './enums.js'; +export * from './errors.js'; +export * from './guards.js'; +export * from './schemas.js'; +export * from './specTypeSchema.js'; +export * from './types.js'; diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts new file mode 100644 index 0000000..a243c1b --- /dev/null +++ b/packages/core/src/types/schemas.ts @@ -0,0 +1,2241 @@ +import * as z from 'zod/v4'; + +import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import type { + JSONArray, + JSONObject, + JSONValue, + NotificationMethod, + NotificationTypeMap, + RequestMethod, + RequestTypeMap, + ResultTypeMap +} from './types.js'; + +export const JSONValueSchema: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.string(), JSONValueSchema), z.array(JSONValueSchema)]) +); +export const JSONObjectSchema: z.ZodType = z.record(z.string(), JSONValueSchema); +export const JSONArraySchema: z.ZodType = z.array(JSONValueSchema); +/** + * A progress token, used to associate progress notifications with the original request. + */ +export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); + +/** + * An opaque token used to represent a cursor for pagination. + */ +export const CursorSchema = z.string(); + +/** + * Task creation parameters, used to ask that the server create a task to represent a request. + */ +export const TaskCreationParamsSchema = z.looseObject({ + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl: z.number().optional(), + + /** + * Time in milliseconds to wait between task status requests. + */ + pollInterval: z.number().optional() +}); + +export const TaskMetadataSchema = z.object({ + ttl: z.number().optional() +}); + +/** + * Metadata for associating messages with a task. + * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + */ +export const RelatedTaskMetadataSchema = z.object({ + taskId: z.string() +}); + +export const RequestMetaSchema = z.looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * If specified, this request is related to the provided task. + */ + [RELATED_TASK_META_KEY]: RelatedTaskMetadataSchema.optional() +}); + +/** + * Common params for any request. + */ +export const BaseRequestParamsSchema = z.object({ + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta: RequestMetaSchema.optional() +}); + +/** + * Common params for any task-augmented request. + */ +export const TaskAugmentedRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a `CreateTaskResult` immediately, and the actual result can be + * retrieved later via `tasks/result`. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task: TaskMetadataSchema.optional() +}); + +export const RequestSchema = z.object({ + method: z.string(), + params: BaseRequestParamsSchema.loose().optional() +}); + +export const NotificationsParamsSchema = z.object({ + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: RequestMetaSchema.optional() +}); + +export const NotificationSchema = z.object({ + method: z.string(), + params: NotificationsParamsSchema.loose().optional() +}); + +export const ResultSchema = z.looseObject({ + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: RequestMetaSchema.optional() +}); + +/** + * A uniquely identifying ID for a request in JSON-RPC. + */ +export const RequestIdSchema = z.union([z.string(), z.number().int()]); + +/** + * A request that expects a response. + */ +export const JSONRPCRequestSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + id: RequestIdSchema, + ...RequestSchema.shape + }) + .strict(); + +/** + * A notification which does not expect a response. + */ +export const JSONRPCNotificationSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + ...NotificationSchema.shape + }) + .strict(); + +/** + * A successful (non-error) response to a request. + */ +export const JSONRPCResultResponseSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + id: RequestIdSchema, + result: ResultSchema + }) + .strict(); + +/** + * A response to a request that indicates an error occurred. + */ +export const JSONRPCErrorResponseSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + id: RequestIdSchema.optional(), + error: z.object({ + /** + * The error type that occurred. + */ + code: z.number().int(), + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: z.string(), + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data: z.unknown().optional() + }) + }) + .strict(); + +export const JSONRPCMessageSchema = z.union([ + JSONRPCRequestSchema, + JSONRPCNotificationSchema, + JSONRPCResultResponseSchema, + JSONRPCErrorResponseSchema +]); + +export const JSONRPCResponseSchema = z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema]); + +/* Empty result */ +/** + * A response that indicates success but carries no data. + */ +export const EmptyResultSchema = ResultSchema.strict(); + +export const CancelledNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + */ + requestId: RequestIdSchema.optional(), + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason: z.string().optional() +}); +/* Cancellation */ +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its {@linkcode InitializeRequest | initialize} request. + */ +export const CancelledNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/cancelled'), + params: CancelledNotificationParamsSchema +}); + +/* Base Metadata */ +/** + * Icon schema for use in {@link Tool | tools}, {@link Prompt | prompts}, {@link Resource | resources}, and {@link Implementation | implementations}. + */ +export const IconSchema = z.object({ + /** + * URL or data URI for the icon. + */ + src: z.string(), + /** + * Optional MIME type for the icon. + */ + mimeType: z.string().optional(), + /** + * 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. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes: z.array(z.string()).optional(), + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme: z.enum(['light', 'dark']).optional() +}); + +/** + * Base schema to add `icons` property. + * + */ +export const IconsSchema = z.object({ + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons: z.array(IconSchema).optional() +}); + +/** + * Base metadata interface for common properties across {@link Resource | resources}, {@link Tool | tools}, {@link Prompt | prompts}, and {@link Implementation | implementations}. + */ +export const BaseMetadataSchema = z.object({ + /** Intended for programmatic or logical use, but used as a display name in past specs or fallback */ + name: z.string(), + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the `name` should be used for display (except for `Tool`, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title: z.string().optional() +}); + +/* Initialization */ +/** + * Describes the name and version of an MCP implementation. + */ +export const ImplementationSchema = BaseMetadataSchema.extend({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + version: z.string(), + /** + * An optional URL of the website for this implementation. + */ + websiteUrl: z.string().optional(), + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description: z.string().optional() +}); + +const FormElicitationCapabilitySchema = z.intersection( + z.object({ + applyDefaults: z.boolean().optional() + }), + JSONObjectSchema +); + +const ElicitationCapabilitySchema = z.preprocess( + value => { + if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0) { + return { form: {} }; + } + return value; + }, + z.intersection( + z.object({ + form: FormElicitationCapabilitySchema.optional(), + url: JSONObjectSchema.optional() + }), + JSONObjectSchema.optional() + ) +); + +/** + * Task capabilities for clients, indicating which request types support task creation. + */ +export const ClientTasksCapabilitySchema = z.looseObject({ + /** + * Present if the client supports listing tasks. + */ + list: JSONObjectSchema.optional(), + /** + * Present if the client supports cancelling tasks. + */ + cancel: JSONObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for sampling requests. + */ + sampling: z + .looseObject({ + createMessage: JSONObjectSchema.optional() + }) + .optional(), + /** + * Task support for elicitation requests. + */ + elicitation: z + .looseObject({ + create: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +/** + * Task capabilities for servers, indicating which request types support task creation. + */ +export const ServerTasksCapabilitySchema = z.looseObject({ + /** + * Present if the server supports listing tasks. + */ + list: JSONObjectSchema.optional(), + /** + * Present if the server supports cancelling tasks. + */ + cancel: JSONObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for tool requests. + */ + tools: z + .looseObject({ + call: JSONObjectSchema.optional() + }) + .optional() + }) + .optional() +}); + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + */ +export const ClientCapabilitiesSchema = z.object({ + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + /** + * Present if the client supports sampling from an LLM. + */ + sampling: z + .object({ + /** + * Present if the client supports context inclusion via `includeContext` parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context: JSONObjectSchema.optional(), + /** + * Present if the client supports tool use via `tools` and `toolChoice` parameters. + */ + tools: JSONObjectSchema.optional() + }) + .optional(), + /** + * Present if the client supports eliciting user input. + */ + elicitation: ElicitationCapabilitySchema.optional(), + /** + * Present if the client supports listing roots. + */ + roots: z + .object({ + /** + * Whether the client supports issuing notifications for changes to the roots list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the client supports task creation. + */ + tasks: ClientTasksCapabilitySchema.optional(), + /** + * Extensions that the client supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +export const InitializeRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema +}); +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + */ +export const InitializeRequestSchema = RequestSchema.extend({ + method: z.literal('initialize'), + params: InitializeRequestParamsSchema +}); + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + */ +export const ServerCapabilitiesSchema = z.object({ + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental: z.record(z.string(), JSONObjectSchema).optional(), + /** + * Present if the server supports sending log messages to the client. + */ + logging: JSONObjectSchema.optional(), + /** + * Present if the server supports sending completions to the client. + */ + completions: JSONObjectSchema.optional(), + /** + * Present if the server offers any prompt templates. + */ + prompts: z + .object({ + /** + * Whether this server supports issuing notifications for changes to the prompt list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the server offers any resources to read. + */ + resources: z + .object({ + /** + * Whether this server supports clients subscribing to resource updates. + */ + subscribe: z.boolean().optional(), + + /** + * Whether this server supports issuing notifications for changes to the resource list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the server offers any tools to call. + */ + tools: z + .object({ + /** + * Whether this server supports issuing notifications for changes to the tool list. + */ + listChanged: z.boolean().optional() + }) + .optional(), + /** + * Present if the server supports task creation. + */ + tasks: ServerTasksCapabilitySchema.optional(), + /** + * Extensions that the server supports. Keys are extension identifiers (vendor-prefix/extension-name). + */ + extensions: z.record(z.string(), JSONObjectSchema).optional() +}); + +/** + * After receiving an initialize request from the client, the server sends this response. + */ +export const InitializeResultSchema = ResultSchema.extend({ + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + /** + * 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. + */ + instructions: z.string().optional() +}); + +/** + * This notification is sent from the client to the server after initialization has finished. + */ +export const InitializedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/initialized'), + params: NotificationsParamsSchema.optional() +}); + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + */ +export const PingRequestSchema = RequestSchema.extend({ + method: z.literal('ping'), + params: BaseRequestParamsSchema.optional() +}); + +/* Progress notifications */ +export const ProgressSchema = z.object({ + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + */ + progress: z.number(), + /** + * Total number of items to process (or total progress required), if known. + */ + total: z.optional(z.number()), + /** + * An optional message describing the current progress. + */ + message: z.optional(z.string()) +}); + +export const ProgressNotificationParamsSchema = z.object({ + ...NotificationsParamsSchema.shape, + ...ProgressSchema.shape, + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressTokenSchema +}); +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category notifications/progress + */ +export const ProgressNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/progress'), + params: ProgressNotificationParamsSchema +}); + +export const PaginatedRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor: CursorSchema.optional() +}); + +/* Pagination */ +export const PaginatedRequestSchema = RequestSchema.extend({ + params: PaginatedRequestParamsSchema.optional() +}); + +export const PaginatedResultSchema = ResultSchema.extend({ + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor: CursorSchema.optional() +}); + +/** + * The status of a task. + * */ +export const TaskStatusSchema = z.enum(['working', 'input_required', 'completed', 'failed', 'cancelled']); + +/* Tasks */ +/** + * A pollable state object associated with a request. + */ +export const TaskSchema = z.object({ + taskId: z.string(), + status: TaskStatusSchema, + /** + * Time in milliseconds to keep task results available after completion. + * If `null`, the task has unlimited lifetime until manually cleaned up. + */ + ttl: z.union([z.number(), z.null()]), + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: z.string(), + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: z.string(), + pollInterval: z.optional(z.number()), + /** + * Optional diagnostic message for failed tasks or other status information. + */ + statusMessage: z.optional(z.string()) +}); + +/** + * Result returned when a task is created, containing the task data wrapped in a `task` field. + */ +export const CreateTaskResultSchema = ResultSchema.extend({ + task: TaskSchema +}); + +/** + * Parameters for task status notification. + */ +export const TaskStatusNotificationParamsSchema = NotificationsParamsSchema.merge(TaskSchema); + +/** + * A notification sent when a task's status changes. + */ +export const TaskStatusNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tasks/status'), + params: TaskStatusNotificationParamsSchema +}); + +/** + * A request to get the state of a specific task. + */ +export const GetTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/get'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode GetTaskRequest | tasks/get} request. + */ +export const GetTaskResultSchema = ResultSchema.merge(TaskSchema); + +/** + * A request to get the result of a specific task. + */ +export const GetTaskPayloadRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/result'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a `tasks/result` request. + * The structure matches the result type of the original request. + * For example, a {@linkcode CallToolRequest | tools/call} task would return the `CallToolResult` structure. + * + */ +export const GetTaskPayloadResultSchema = ResultSchema.loose(); + +/** + * A request to list tasks. + */ +export const ListTasksRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tasks/list') +}); + +/** + * The response to a {@linkcode ListTasksRequest | tasks/list} request. + */ +export const ListTasksResultSchema = PaginatedResultSchema.extend({ + tasks: z.array(TaskSchema) +}); + +/** + * A request to cancel a specific task. + */ +export const CancelTaskRequestSchema = RequestSchema.extend({ + method: z.literal('tasks/cancel'), + params: BaseRequestParamsSchema.extend({ + taskId: z.string() + }) +}); + +/** + * The response to a {@linkcode CancelTaskRequest | tasks/cancel} request. + */ +export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema); + +/* Resources */ +/** + * The contents of a specific resource or sub-resource. + */ +export const ResourceContentsSchema = z.object({ + /** + * The URI of this resource. + */ + uri: z.string(), + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +export const TextResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: z.string() +}); + +/** + * A Zod schema for validating Base64 strings that is more performant and + * robust for very large inputs than the default regex-based check. It avoids + * stack overflows by using the native `atob` function for validation. + */ +const Base64Schema = z.string().refine( + val => { + try { + // atob throws a DOMException if the string contains characters + // that are not part of the Base64 character set. + atob(val); + return true; + } catch { + return false; + } + }, + { message: 'Invalid Base64 string' } +); + +export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ + /** + * A base64-encoded string representing the binary data of the item. + */ + blob: Base64Schema +}); + +/** + * The sender or recipient of messages and data in a conversation. + */ +export const RoleSchema = z.enum(['user', 'assistant']); + +/** + * Optional annotations providing clients additional context about a resource. + */ +export const AnnotationsSchema = z.object({ + /** + * Intended audience(s) for the resource. + */ + audience: z.array(RoleSchema).optional(), + + /** + * Importance hint for the resource, from 0 (least) to 1 (most). + */ + priority: z.number().min(0).max(1).optional(), + + /** + * ISO 8601 timestamp for the most recent modification. + */ + lastModified: z.iso.datetime({ offset: true }).optional() +}); + +/** + * A known resource that the server is capable of reading. + */ +export const ResourceSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * The URI of this resource. + */ + uri: z.string(), + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), + + /** + * The MIME type of this resource, if known. + */ + mimeType: z.optional(z.string()), + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size: z.optional(z.number()), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * A template description for resources available on the server. + */ +export const ResourceTemplateSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + */ + uriTemplate: z.string(), + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description: z.optional(z.string()), + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType: z.optional(z.string()), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * Sent from the client to request a list of resources the server has. + */ +export const ListResourcesRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('resources/list') +}); + +/** + * The server's response to a {@linkcode ListResourcesRequest | resources/list} request from the client. + */ +export const ListResourcesResultSchema = PaginatedResultSchema.extend({ + resources: z.array(ResourceSchema) +}); + +/** + * Sent from the client to request a list of resource templates the server has. + */ +export const ListResourceTemplatesRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('resources/templates/list') +}); + +/** + * The server's response to a {@linkcode ListResourceTemplatesRequest | resources/templates/list} request from the client. + */ +export const ListResourceTemplatesResultSchema = PaginatedResultSchema.extend({ + resourceTemplates: z.array(ResourceTemplateSchema) +}); + +export const ResourceRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: z.string() +}); + +/** + * Parameters for a {@linkcode ReadResourceRequest | resources/read} request. + */ +export const ReadResourceRequestParamsSchema = ResourceRequestParamsSchema; + +/** + * Sent from the client to the server, to read a specific resource URI. + */ +export const ReadResourceRequestSchema = RequestSchema.extend({ + method: z.literal('resources/read'), + params: ReadResourceRequestParamsSchema +}); + +/** + * The server's response to a {@linkcode ReadResourceRequest | resources/read} request from the client. + */ +export const ReadResourceResultSchema = ResultSchema.extend({ + contents: z.array(z.union([TextResourceContentsSchema, BlobResourceContentsSchema])) +}); + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + */ +export const ResourceListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +export const SubscribeRequestParamsSchema = ResourceRequestParamsSchema; +/** + * Sent from the client to request `resources/updated` notifications from the server whenever a particular resource changes. + */ +export const SubscribeRequestSchema = RequestSchema.extend({ + method: z.literal('resources/subscribe'), + params: SubscribeRequestParamsSchema +}); + +export const UnsubscribeRequestParamsSchema = ResourceRequestParamsSchema; +/** + * Sent from the client to request cancellation of {@linkcode ResourceUpdatedNotification | resources/updated} notifications from the server. This should follow a previous {@linkcode SubscribeRequest | resources/subscribe} request. + */ +export const UnsubscribeRequestSchema = RequestSchema.extend({ + method: z.literal('resources/unsubscribe'), + params: UnsubscribeRequestParamsSchema +}); + +/** + * Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification. + */ +export const ResourceUpdatedNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + */ + uri: z.string() +}); + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a {@linkcode SubscribeRequest | resources/subscribe} request. + */ +export const ResourceUpdatedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/resources/updated'), + params: ResourceUpdatedNotificationParamsSchema +}); + +/* Prompts */ +/** + * Describes an argument that a prompt can accept. + */ +export const PromptArgumentSchema = z.object({ + /** + * The name of the argument. + */ + name: z.string(), + /** + * A human-readable description of the argument. + */ + description: z.optional(z.string()), + /** + * Whether this argument must be provided. + */ + required: z.optional(z.boolean()) +}); + +/** + * A prompt or prompt template that the server offers. + */ +export const PromptSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * An optional description of what this prompt provides + */ + description: z.optional(z.string()), + /** + * A list of arguments to use for templating the prompt. + */ + arguments: z.optional(z.array(PromptArgumentSchema)), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.optional(z.looseObject({})) +}); + +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + */ +export const ListPromptsRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('prompts/list') +}); + +/** + * The server's response to a {@linkcode ListPromptsRequest | prompts/list} request from the client. + */ +export const ListPromptsResultSchema = PaginatedResultSchema.extend({ + prompts: z.array(PromptSchema) +}); + +/** + * Parameters for a {@linkcode GetPromptRequest | prompts/get} request. + */ +export const GetPromptRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * The name of the prompt or prompt template. + */ + name: z.string(), + /** + * Arguments to use for templating the prompt. + */ + arguments: z.record(z.string(), z.string()).optional() +}); +/** + * Used by the client to get a prompt provided by the server. + */ +export const GetPromptRequestSchema = RequestSchema.extend({ + method: z.literal('prompts/get'), + params: GetPromptRequestParamsSchema +}); + +/** + * Text provided to or from an LLM. + */ +export const TextContentSchema = z.object({ + type: z.literal('text'), + /** + * The text content of the message. + */ + text: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * An image provided to or from an LLM. + */ +export const ImageContentSchema = z.object({ + type: z.literal('image'), + /** + * The base64-encoded image data. + */ + data: Base64Schema, + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Audio content provided to or from an LLM. + */ +export const AudioContentSchema = z.object({ + type: z.literal('audio'), + /** + * The base64-encoded audio data. + */ + data: Base64Schema, + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: z.string(), + + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * A tool call request from an assistant (LLM). + * Represents the assistant's request to use a tool. + */ +export const ToolUseContentSchema = z.object({ + type: z.literal('tool_use'), + /** + * The name of the tool to invoke. + * Must match a tool name from the request's tools array. + */ + name: z.string(), + /** + * Unique identifier for this tool call. + * Used to correlate with `ToolResultContent` in subsequent messages. + */ + id: z.string(), + /** + * Arguments to pass to the tool. + * Must conform to the tool's `inputSchema`. + */ + input: z.record(z.string(), z.unknown()), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * The contents of a resource, embedded into a prompt or tool call result. + */ +export const EmbeddedResourceSchema = z.object({ + type: z.literal('resource'), + resource: z.union([TextResourceContentsSchema, BlobResourceContentsSchema]), + /** + * Optional annotations for the client. + */ + annotations: AnnotationsSchema.optional(), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of {@linkcode ListResourcesRequest | resources/list} requests. + */ +export const ResourceLinkSchema = ResourceSchema.extend({ + type: z.literal('resource_link') +}); + +/** + * A content block that can be used in prompts and tool results. + */ +export const ContentBlockSchema = z.union([ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ResourceLinkSchema, + EmbeddedResourceSchema +]); + +/** + * Describes a message returned as part of a prompt. + */ +export const PromptMessageSchema = z.object({ + role: RoleSchema, + content: ContentBlockSchema +}); + +/** + * The server's response to a {@linkcode GetPromptRequest | prompts/get} request from the client. + */ +export const GetPromptResultSchema = ResultSchema.extend({ + /** + * An optional description for the prompt. + */ + description: z.string().optional(), + messages: z.array(PromptMessageSchema) +}); + +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + */ +export const PromptListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/prompts/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/* Tools */ +/** + * Additional properties describing a `Tool` to clients. + * + * NOTE: all properties in {@linkcode ToolAnnotations} are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on `ToolAnnotations` + * received from untrusted servers. + */ +export const ToolAnnotationsSchema = z.object({ + /** + * A human-readable title for the tool. + */ + title: z.string().optional(), + + /** + * If `true`, the tool does not modify its environment. + * + * Default: `false` + */ + readOnlyHint: z.boolean().optional(), + + /** + * If `true`, the tool may perform destructive updates to its environment. + * If `false`, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: `true` + */ + destructiveHint: z.boolean().optional(), + + /** + * If `true`, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: `false` + */ + idempotentHint: z.boolean().optional(), + + /** + * 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. + * + * Default: `true` + */ + openWorldHint: z.boolean().optional() +}); + +/** + * Execution-related properties for a tool. + */ +export const ToolExecutionSchema = z.object({ + /** + * Indicates the tool's preference for task-augmented execution. + * - `"required"`: Clients MUST invoke the tool as a task + * - `"optional"`: Clients MAY invoke the tool as a task or normal request + * - `"forbidden"`: Clients MUST NOT attempt to invoke the tool as a task + * + * If not present, defaults to `"forbidden"`. + */ + taskSupport: z.enum(['required', 'optional', 'forbidden']).optional() +}); + +/** + * Definition for a tool the client can call. + */ +export const ToolSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, + /** + * A human-readable description of the tool. + */ + description: z.string().optional(), + /** + * A JSON Schema 2020-12 object defining the expected parameters for the tool. + * Must have `type: 'object'` at the root level per MCP spec. + */ + inputSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), JSONValueSchema).optional(), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()), + /** + * An optional JSON Schema 2020-12 object defining the structure of the tool's output + * returned in the `structuredContent` field of a `CallToolResult`. + * Must have `type: 'object'` at the root level per MCP spec. + */ + outputSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), JSONValueSchema).optional(), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()) + .optional(), + /** + * Optional additional tool information. + */ + annotations: ToolAnnotationsSchema.optional(), + /** + * Execution-related properties for this tool. + */ + execution: ToolExecutionSchema.optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Sent from the client to request a list of tools the server has. + */ +export const ListToolsRequestSchema = PaginatedRequestSchema.extend({ + method: z.literal('tools/list') +}); + +/** + * The server's response to a {@linkcode ListToolsRequest | tools/list} request from the client. + */ +export const ListToolsResultSchema = PaginatedResultSchema.extend({ + tools: z.array(ToolSchema) +}); + +/** + * The server's response to a tool call. + */ +export const CallToolResultSchema = ResultSchema.extend({ + /** + * A list of content objects that represent the result of the tool call. + * + * If the `Tool` does not define an outputSchema, this field MUST be present in the result. + * For backwards compatibility, this field is always present, but it may be empty. + */ + content: z.array(ContentBlockSchema).default([]), + + /** + * An object containing structured tool output. + * + * If the `Tool` defines an outputSchema, this field MUST be present in the result, and contain a JSON object that matches the schema. + */ + structuredContent: z.record(z.string(), z.unknown()).optional(), + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be `false` (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to `true`, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError: z.boolean().optional() +}); + +/** + * {@linkcode CallToolResultSchema} extended with backwards compatibility to protocol version 2024-10-07. + */ +export const CompatibilityCallToolResultSchema = CallToolResultSchema.or( + ResultSchema.extend({ + toolResult: z.unknown() + }) +); + +/** + * Parameters for a `tools/call` request. + */ +export const CallToolRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The name of the tool to call. + */ + name: z.string(), + /** + * Arguments to pass to the tool. + */ + arguments: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Used by the client to invoke a tool provided by the server. + */ +export const CallToolRequestSchema = RequestSchema.extend({ + method: z.literal('tools/call'), + params: CallToolRequestParamsSchema +}); + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + */ +export const ToolListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/tools/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/** + * Base schema for list changed subscription options (without callback). + * Used internally for Zod validation of `autoRefresh` and `debounceMs`. + */ +export const ListChangedOptionsBaseSchema = z.object({ + /** + * If `true`, the list will be refreshed automatically when a list changed notification is received. + * The callback will be called with the updated list. + * + * If `false`, the callback will be called with `null` items, allowing manual refresh. + * + * @default true + */ + autoRefresh: z.boolean().default(true), + /** + * Debounce time in milliseconds for list changed notification processing. + * + * Multiple notifications received within this timeframe will only trigger one refresh. + * Set to `0` to disable debouncing. + * + * @default 300 + */ + debounceMs: z.number().int().nonnegative().default(300) +}); + +/* Logging */ +/** + * The severity of a log message. + */ +export const LoggingLevelSchema = z.enum(['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']); + +/** + * Parameters for a `logging/setLevel` request. + */ +export const SetLevelRequestParamsSchema = BaseRequestParamsSchema.extend({ + /** + * 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/logging/message`. + */ + level: LoggingLevelSchema +}); +/** + * A request from the client to the server, to enable or adjust logging. + */ +export const SetLevelRequestSchema = RequestSchema.extend({ + method: z.literal('logging/setLevel'), + params: SetLevelRequestParamsSchema +}); + +/** + * Parameters for a `notifications/message` notification. + */ +export const LoggingMessageNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The severity of this log message. + */ + level: LoggingLevelSchema, + /** + * An optional name of the logger issuing this message. + */ + logger: z.string().optional(), + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: z.unknown() +}); +/** + * Notification of a log message passed from server to client. If no `logging/setLevel` request has been sent from the client, the server MAY decide which messages to send automatically. + */ +export const LoggingMessageNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/message'), + params: LoggingMessageNotificationParamsSchema +}); + +/* Sampling */ +/** + * Hints to use for model selection. + */ +export const ModelHintSchema = z.object({ + /** + * A hint for a model name. + */ + name: z.string().optional() +}); + +/** + * The server's preferences for model selection, requested of the client during sampling. + */ +export const ModelPreferencesSchema = z.object({ + /** + * Optional hints to use for model selection. + */ + hints: z.array(ModelHintSchema).optional(), + /** + * How much to prioritize cost when selecting a model. + */ + costPriority: z.number().min(0).max(1).optional(), + /** + * How much to prioritize sampling speed (latency) when selecting a model. + */ + speedPriority: z.number().min(0).max(1).optional(), + /** + * How much to prioritize intelligence and capabilities when selecting a model. + */ + intelligencePriority: z.number().min(0).max(1).optional() +}); + +/** + * Controls tool usage behavior in sampling requests. + */ +export const ToolChoiceSchema = z.object({ + /** + * Controls when tools are used: + * - `"auto"`: Model decides whether to use tools (default) + * - `"required"`: Model MUST use at least one tool before completing + * - `"none"`: Model MUST NOT use any tools + */ + mode: z.enum(['auto', 'required', 'none']).optional() +}); + +/** + * The result of a tool execution, provided by the user (server). + * Represents the outcome of invoking a tool requested via `ToolUseContent`. + */ +export const ToolResultContentSchema = z.object({ + type: z.literal('tool_result'), + toolUseId: z.string().describe('The unique identifier for the corresponding tool call.'), + content: z.array(ContentBlockSchema).default([]), + structuredContent: z.object({}).loose().optional(), + isError: z.boolean().optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Basic content types for sampling responses (without tool use). + * Used for backwards-compatible {@linkcode CreateMessageResult} when tools are not used. + */ +export const SamplingContentSchema = z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]); + +/** + * Content block types allowed in sampling messages. + * This includes text, image, audio, tool use requests, and tool results. + */ +export const SamplingMessageContentBlockSchema = z.discriminatedUnion('type', [ + TextContentSchema, + ImageContentSchema, + AudioContentSchema, + ToolUseContentSchema, + ToolResultContentSchema +]); + +/** + * Describes a message issued to or received from an LLM API. + */ +export const SamplingMessageSchema = z.object({ + role: RoleSchema, + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]), + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Parameters for a `sampling/createMessage` request. + */ +export const CreateMessageRequestParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + messages: z.array(SamplingMessageSchema), + /** + * The server's preferences for which model to select. The client MAY modify or omit this request. + */ + modelPreferences: ModelPreferencesSchema.optional(), + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt: z.string().optional(), + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is `"none"`. Values `"thisServer"` and `"allServers"` are soft-deprecated. Servers SHOULD only use these values if the client + * declares `ClientCapabilities`.`sampling.context`. These values may be removed in future spec releases. + */ + includeContext: z.enum(['none', 'thisServer', 'allServers']).optional(), + temperature: z.number().optional(), + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: z.number().int(), + stopSequences: z.array(z.string()).optional(), + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata: JSONObjectSchema.optional(), + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but `ClientCapabilities`.`sampling.tools` is not declared. + */ + tools: z.array(ToolSchema).optional(), + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but `ClientCapabilities`.`sampling.tools` is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice: ToolChoiceSchema.optional() +}); +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + */ +export const CreateMessageRequestSchema = RequestSchema.extend({ + method: z.literal('sampling/createMessage'), + params: CreateMessageRequestParamsSchema +}); + +/** + * The client's response to a `sampling/create_message` request from the server. + * This is the backwards-compatible version that returns single content (no arrays). + * Used when the request does not include tools. + */ +export const CreateMessageResultSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens']).or(z.string())), + role: RoleSchema, + /** + * Response content. Single content block (text, image, or audio). + */ + content: SamplingContentSchema +}); + +/** + * The client's response to a `sampling/create_message` request when tools were provided. + * This version supports array content for tool use flows. + */ +export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * - `"toolUse"`: The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens', 'toolUse']).or(z.string())), + role: RoleSchema, + /** + * Response content. May be a single block or array. May include `ToolUseContent` if `stopReason` is `"toolUse"`. + */ + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]) +}); + +/* Elicitation */ +/** + * Primitive schema definition for boolean fields. + */ +export const BooleanSchemaSchema = z.object({ + type: z.literal('boolean'), + title: z.string().optional(), + description: z.string().optional(), + default: z.boolean().optional() +}); + +/** + * Primitive schema definition for string fields. + */ +export const StringSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + format: z.enum(['email', 'uri', 'date', 'date-time']).optional(), + default: z.string().optional() +}); + +/** + * Primitive schema definition for number fields. + */ +export const NumberSchemaSchema = z.object({ + type: z.enum(['number', 'integer']), + title: z.string().optional(), + description: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + default: z.number().optional() +}); + +/** + * Schema for single-selection enumeration without display titles for options. + */ +export const UntitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + default: z.string().optional() +}); + +/** + * Schema for single-selection enumeration with display titles for each option. + */ +export const TitledSingleSelectEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + oneOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ), + default: z.string().optional() +}); + +/** + * Use {@linkcode TitledSingleSelectEnumSchema} instead. + * This interface will be removed in a future version. + */ +export const LegacyTitledEnumSchemaSchema = z.object({ + type: z.literal('string'), + title: z.string().optional(), + description: z.string().optional(), + enum: z.array(z.string()), + enumNames: z.array(z.string()).optional(), + default: z.string().optional() +}); + +// Combined single selection enumeration +export const SingleSelectEnumSchemaSchema = z.union([UntitledSingleSelectEnumSchemaSchema, TitledSingleSelectEnumSchemaSchema]); + +/** + * Schema for multiple-selection enumeration without display titles for options. + */ +export const UntitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + type: z.literal('string'), + enum: z.array(z.string()) + }), + default: z.array(z.string()).optional() +}); + +/** + * Schema for multiple-selection enumeration with display titles for each option. + */ +export const TitledMultiSelectEnumSchemaSchema = z.object({ + type: z.literal('array'), + title: z.string().optional(), + description: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + items: z.object({ + anyOf: z.array( + z.object({ + const: z.string(), + title: z.string() + }) + ) + }), + default: z.array(z.string()).optional() +}); + +/** + * Combined schema for multiple-selection enumeration + */ +export const MultiSelectEnumSchemaSchema = z.union([UntitledMultiSelectEnumSchemaSchema, TitledMultiSelectEnumSchemaSchema]); + +/** + * Primitive schema definition for enum fields. + */ +export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSelectEnumSchemaSchema, MultiSelectEnumSchemaSchema]); + +/** + * Union of all primitive schema definitions. + */ +export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); + +/** + * Parameters for an `elicitation/create` request for form-based elicitation. + */ +export const ElicitRequestFormParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + * + * Optional for backward compatibility. Clients MUST treat missing `mode` as `"form"`. + */ + mode: z.literal('form').optional(), + /** + * The message to present to the user describing what information is being requested. + */ + message: z.string(), + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: z + .object({ + type: z.literal('object'), + properties: z.record(z.string(), PrimitiveSchemaDefinitionSchema), + required: z.array(z.string()).optional() + }) + .catchall(z.unknown()) +}); + +/** + * Parameters for an {@linkcode ElicitRequest | elicitation/create} request for URL-based elicitation. + */ +export const ElicitRequestURLParamsSchema = TaskAugmentedRequestParamsSchema.extend({ + /** + * The elicitation mode. + */ + mode: z.literal('url'), + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: z.string(), + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: z.string(), + /** + * The URL that the user should navigate to. + */ + url: z.string().url() +}); + +/** + * The parameters for a request to elicit additional information from the user via the client. + */ +export const ElicitRequestParamsSchema = z.union([ElicitRequestFormParamsSchema, ElicitRequestURLParamsSchema]); + +/** + * A request from the server to elicit user input via the client. + * The client should present the message and form fields to the user (form mode) + * or navigate to a URL (URL mode). + */ +export const ElicitRequestSchema = RequestSchema.extend({ + method: z.literal('elicitation/create'), + params: ElicitRequestParamsSchema +}); + +/** + * Parameters for a {@linkcode ElicitationCompleteNotification | notifications/elicitation/complete} notification. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationParamsSchema = NotificationsParamsSchema.extend({ + /** + * The ID of the elicitation that completed. + */ + elicitationId: z.string() +}); + +/** + * A notification from the server to the client, informing it of a completion of an out-of-band elicitation request. + * + * @category notifications/elicitation/complete + */ +export const ElicitationCompleteNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/elicitation/complete'), + params: ElicitationCompleteNotificationParamsSchema +}); + +/** + * The client's response to an {@linkcode ElicitRequest | elicitation/create} request from the server. + */ +export const ElicitResultSchema = ResultSchema.extend({ + /** + * The user action in response to the elicitation. + * - `"accept"`: User submitted the form/confirmed the action + * - `"decline"`: User explicitly declined the action + * - `"cancel"`: User dismissed without making an explicit choice + */ + action: z.enum(['accept', 'decline', 'cancel']), + /** + * The submitted form data, only present when action is `"accept"`. + * Contains values matching the requested schema. + * Per MCP spec, content is "typically omitted" for decline/cancel actions. + * We normalize `null` to `undefined` for leniency while maintaining type compatibility. + */ + content: z.preprocess( + val => (val === null ? undefined : val), + z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() + ) +}); + +/* Autocomplete */ +/** + * A reference to a resource or resource template definition. + */ +export const ResourceTemplateReferenceSchema = z.object({ + type: z.literal('ref/resource'), + /** + * The URI or URI template of the resource. + */ + uri: z.string() +}); + +/** + * Identifies a prompt. + */ +export const PromptReferenceSchema = z.object({ + type: z.literal('ref/prompt'), + /** + * The name of the prompt or prompt template + */ + name: z.string() +}); + +/** + * Parameters for a {@linkcode CompleteRequest | completion/complete} request. + */ +export const CompleteRequestParamsSchema = BaseRequestParamsSchema.extend({ + ref: z.union([PromptReferenceSchema, ResourceTemplateReferenceSchema]), + /** + * The argument's information + */ + argument: z.object({ + /** + * The name of the argument + */ + name: z.string(), + /** + * The value of the argument to use for completion matching. + */ + value: z.string() + }), + context: z + .object({ + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments: z.record(z.string(), z.string()).optional() + }) + .optional() +}); +/** + * A request from the client to the server, to ask for completion options. + */ +export const CompleteRequestSchema = RequestSchema.extend({ + method: z.literal('completion/complete'), + params: CompleteRequestParamsSchema +}); + +/** + * The server's response to a {@linkcode CompleteRequest | completion/complete} request + */ +export const CompleteResultSchema = ResultSchema.extend({ + completion: z.looseObject({ + /** + * An array of completion values. Must not exceed 100 items. + */ + values: z.array(z.string()).max(100), + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total: z.optional(z.number().int()), + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore: z.optional(z.boolean()) + }) +}); + +/* Roots */ +/** + * Represents a root directory or file that the server can operate on. + */ +export const RootSchema = z.object({ + /** + * The URI identifying the root. This *must* start with `file://` for now. + */ + uri: z.string().startsWith('file://'), + /** + * An optional name for the root. + */ + name: z.string().optional(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on `_meta` usage. + */ + _meta: z.record(z.string(), z.unknown()).optional() +}); + +/** + * Sent from the server to request a list of root URIs from the client. + */ +export const ListRootsRequestSchema = RequestSchema.extend({ + method: z.literal('roots/list'), + params: BaseRequestParamsSchema.optional() +}); + +/** + * The client's response to a `roots/list` request from the server. + */ +export const ListRootsResultSchema = ResultSchema.extend({ + roots: z.array(RootSchema) +}); + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + */ +export const RootsListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/roots/list_changed'), + params: NotificationsParamsSchema.optional() +}); + +/* Client messages */ +export const ClientRequestSchema = z.union([ + PingRequestSchema, + InitializeRequestSchema, + CompleteRequestSchema, + SetLevelRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + SubscribeRequestSchema, + UnsubscribeRequestSchema, + CallToolRequestSchema, + ListToolsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ClientNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema +]); + +export const ClientResultSchema = z.union([ + EmptyResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitResultSchema, + ListRootsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +/* Server messages */ +export const ServerRequestSchema = z.union([ + PingRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + CancelTaskRequestSchema +]); + +export const ServerNotificationSchema = z.union([ + CancelledNotificationSchema, + ProgressNotificationSchema, + LoggingMessageNotificationSchema, + ResourceUpdatedNotificationSchema, + ResourceListChangedNotificationSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + TaskStatusNotificationSchema, + ElicitationCompleteNotificationSchema +]); + +export const ServerResultSchema = z.union([ + EmptyResultSchema, + InitializeResultSchema, + CompleteResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ReadResourceResultSchema, + CallToolResultSchema, + ListToolsResultSchema, + GetTaskResultSchema, + ListTasksResultSchema, + CreateTaskResultSchema +]); + +/* Runtime schema lookup — result schemas by method */ +const resultSchemas: Record = { + ping: EmptyResultSchema, + initialize: InitializeResultSchema, + 'completion/complete': CompleteResultSchema, + 'logging/setLevel': EmptyResultSchema, + 'prompts/get': GetPromptResultSchema, + 'prompts/list': ListPromptsResultSchema, + 'resources/list': ListResourcesResultSchema, + 'resources/templates/list': ListResourceTemplatesResultSchema, + 'resources/read': ReadResourceResultSchema, + 'resources/subscribe': EmptyResultSchema, + 'resources/unsubscribe': EmptyResultSchema, + 'tools/call': z.union([CallToolResultSchema, CreateTaskResultSchema]), + 'tools/list': ListToolsResultSchema, + 'sampling/createMessage': z.union([CreateMessageResultWithToolsSchema, CreateTaskResultSchema]), + 'elicitation/create': z.union([ElicitResultSchema, CreateTaskResultSchema]), + 'roots/list': ListRootsResultSchema, + 'tasks/get': GetTaskResultSchema, + 'tasks/result': ResultSchema, + 'tasks/list': ListTasksResultSchema, + 'tasks/cancel': CancelTaskResultSchema +}; + +/** + * Gets the Zod schema for validating results of a given request method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for explanation of the internal type assertion. + */ +export function getResultSchema(method: M): z.ZodType; +export function getResultSchema(method: string): z.ZodType | undefined; +export function getResultSchema(method: string): z.ZodType | undefined { + return resultSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; +} + +/* Runtime schema lookup — request schemas by method */ +type RequestSchemaType = (typeof ClientRequestSchema.options)[number] | (typeof ServerRequestSchema.options)[number]; +type NotificationSchemaType = (typeof ClientNotificationSchema.options)[number] | (typeof ServerNotificationSchema.options)[number]; + +function buildSchemaMap(schemas: readonly T[]): Record { + const map: Record = {}; + for (const schema of schemas) { + const method = schema.shape.method.value; + map[method] = schema; + } + return map; +} + +const requestSchemas = buildSchemaMap([...ClientRequestSchema.options, ...ServerRequestSchema.options] as const) as Record< + RequestMethod, + RequestSchemaType +>; +const notificationSchemas = buildSchemaMap([...ClientNotificationSchema.options, ...ServerNotificationSchema.options] as const) as Record< + NotificationMethod, + NotificationSchemaType +>; + +/** + * Gets the Zod schema for a given request method. + * Returns `undefined` for non-spec methods. + * The return type is a ZodType that parses to RequestTypeMap[M], allowing callers + * to use schema.parse() without needing additional type assertions. + * + * Note: The internal cast is necessary because TypeScript can't correlate the + * Record-based schema lookup with the MethodToTypeMap-based RequestTypeMap + * when M is a generic type parameter. Both compute to the same type at + * instantiation, but TypeScript can't prove this statically. + */ +export function getRequestSchema(method: M): z.ZodType; +export function getRequestSchema(method: string): z.ZodType | undefined; +export function getRequestSchema(method: string): z.ZodType | undefined { + return requestSchemas[method as RequestMethod] as unknown as z.ZodType | undefined; +} + +/** + * Gets the Zod schema for a given notification method. + * Returns `undefined` for non-spec methods. + * @see getRequestSchema for explanation of the internal type assertion. + */ +export function getNotificationSchema(method: M): z.ZodType; +export function getNotificationSchema(method: string): z.ZodType | undefined; +export function getNotificationSchema(method: string): z.ZodType | undefined { + return notificationSchemas[method as NotificationMethod] as unknown as z.ZodType | undefined; +} diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.ts new file mode 100644 index 0000000..a03f21f --- /dev/null +++ b/packages/core/src/types/spec.types.ts @@ -0,0 +1,3250 @@ +/** + * This file is automatically generated from the Model Context Protocol specification. + * + * Source: https://github.com/modelcontextprotocol/modelcontextprotocol + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts + * Last updated from commit: 5c25208be86db5033f644a4e0d005e08f699ef3d + * + * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. + * To update this file, run: pnpm run fetch:spec-types + */ /* JSON types */ + +/** + * @category Common Types + */ +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; + +/** + * @category Common Types + */ +export type JSONObject = { [key: string]: JSONValue }; + +/** + * @category Common Types + */ +export type JSONArray = JSONValue[]; + +/* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = 'DRAFT-2026-v1'; +/** @internal */ +export const JSONRPC_VERSION = '2.0'; + +/** + * Represents the contents of a `_meta` field, which clients and servers use to attach additional metadata to their interactions. + * + * Certain key names are reserved by MCP for protocol-level metadata; implementations MUST NOT make assumptions about values at these keys. Additionally, specific schema definitions may reserve particular names for purpose-specific metadata, as declared in those definitions. + * + * Valid keys have two segments: + * + * **Prefix:** + * - Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`). + * - Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`). + * - Any prefix consisting of zero or more labels, followed by `modelcontextprotocol` or `mcp`, followed by any label, is **reserved** for MCP use. For example: `modelcontextprotocol.io/`, `mcp.dev/`, `api.modelcontextprotocol.org/`, and `tools.mcp.com/` are all reserved. + * + * **Name:** + * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). + * - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). + * + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export type MetaObject = Record; + +/** + * Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. + * + * @see {@link MetaObject} for key naming rules and reserved prefixes. + * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @category Common Types + */ +export interface RequestMetaObject extends MetaObject { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotification | notifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; +} + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** + * Common params for any task-augmented request. + * + * @internal + */ +export interface TaskAugmentedRequestParams extends RequestParams { + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a {@link CreateTaskResult} immediately, and the actual result can be + * retrieved later via {@link GetTaskPayloadRequest | tasks/result}. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task?: TaskMetadata; +} + +/** + * Common params for any request. + * + * @category Common Types + */ +export interface RequestParams { + _meta?: RequestMetaObject; +} + +/** @internal */ +export interface Request { + method: string; + // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * Common params for any notification. + * + * @category Common Types + */ +export interface NotificationParams { + _meta?: MetaObject; +} + +/** @internal */ +export interface Notification { + method: string; + // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * Common result fields. + * + * @category Common Types + */ +export interface Result { + _meta?: MetaObject; + [key: string]: unknown; +} + +/** + * @category Errors + */ +export interface Error { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResultResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; +} + +/** + * A response to a request, containing either the result or error. + * + * @category JSON-RPC + */ +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +/** + * A JSON-RPC error indicating that invalid JSON was received by the server. This error is returned when the server cannot parse the JSON text of a message. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Invalid JSON + * {@includeCode ./examples/ParseError/invalid-json.json} + * + * @category Errors + */ +export interface ParseError extends Error { + code: typeof PARSE_ERROR; +} + +/** + * A JSON-RPC error indicating that the request is not a valid request object. This error is returned when the message structure does not conform to the JSON-RPC 2.0 specification requirements for a request (e.g., missing required fields like `jsonrpc` or `method`, or using invalid types for these fields). + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @category Errors + */ +export interface InvalidRequestError extends Error { + code: typeof INVALID_REQUEST; +} + +/** + * A JSON-RPC error indicating that the requested method does not exist or is not available. + * + * In MCP, this error is returned when a request is made for a method that requires a capability that has not been declared. This can occur in either direction: + * + * - A server returning this error when the client requests a capability it doesn't support (e.g., requesting completions when the `completions` capability was not advertised) + * - A client returning this error when the server requests a capability it doesn't support (e.g., requesting roots when the client did not declare the `roots` capability) + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Roots not supported + * {@includeCode ./examples/MethodNotFoundError/roots-not-supported.json} + * + * @category Errors + */ +export interface MethodNotFoundError extends Error { + code: typeof METHOD_NOT_FOUND; +} + +/** + * A JSON-RPC error indicating that the method parameters are invalid or malformed. + * + * In MCP, this error is returned in various contexts when request parameters fail validation: + * + * - **Tools**: Unknown tool name or invalid tool arguments + * - **Prompts**: Unknown prompt name or missing required arguments + * - **Pagination**: Invalid or expired cursor values + * - **Logging**: Invalid log level + * - **Tasks**: Invalid or nonexistent task ID, invalid cursor, or attempting to cancel a task already in a terminal status + * - **Elicitation**: Server requests an elicitation mode not declared in client capabilities + * - **Sampling**: Missing tool result or tool results mixed with other content + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unknown tool + * {@includeCode ./examples/InvalidParamsError/unknown-tool.json} + * + * @example Invalid tool arguments + * {@includeCode ./examples/InvalidParamsError/invalid-tool-arguments.json} + * + * @example Unknown prompt + * {@includeCode ./examples/InvalidParamsError/unknown-prompt.json} + * + * @example Invalid cursor + * {@includeCode ./examples/InvalidParamsError/invalid-cursor.json} + * + * @category Errors + */ +export interface InvalidParamsError extends Error { + code: typeof INVALID_PARAMS; +} + +/** + * A JSON-RPC error indicating that an internal error occurred on the receiver. This error is returned when the receiver encounters an unexpected condition that prevents it from fulfilling the request. + * + * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} + * + * @example Unexpected error + * {@includeCode ./examples/InternalError/unexpected-error.json} + * + * @category Errors + */ +export interface InternalError extends Error { + code: typeof INTERNAL_ERROR; +} + +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + +/** + * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * + * @example Authorization required + * {@includeCode ./examples/URLElicitationRequiredError/authorization-required.json} + * + * @internal + */ +export interface URLElicitationRequiredError extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A result that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotificationParams/user-requested-cancellation.json} + * + * @category `notifications/cancelled` + */ +export interface CancelledNotificationParams extends NotificationParams { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST be provided for cancelling non-task requests. + * This MUST NOT be used for cancelling tasks (use the {@link CancelTaskRequest | tasks/cancel} request instead). + */ + requestId?: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; +} + +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + * + * For task cancellation, use the {@link CancelTaskRequest | tasks/cancel} request instead of this notification. + * + * @example User-requested cancellation + * {@includeCode ./examples/CancelledNotification/user-requested-cancellation.json} + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: 'notifications/cancelled'; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @example Full client capabilities + * {@includeCode ./examples/InitializeRequestParams/full-client-capabilities.json} + * + * @category `initialize` + */ +export interface InitializeRequestParams extends RequestParams { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; +} + +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + * + * @example Initialize request + * {@includeCode ./examples/InitializeRequest/initialize-request.json} + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: 'initialize'; + params: InitializeRequestParams; +} + +/** + * The result returned by the server for an {@link InitializeRequest | initialize} request. + * + * @example Full server capabilities + * {@includeCode ./examples/InitializeResult/full-server-capabilities.json} + * + * @category `initialize` + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * Instructions should focus on information that helps the model use the server effectively (e.g., cross-tool relationships, workflow patterns, constraints), but should not duplicate information already in tool descriptions. + * + * Clients MAY add this information to the system prompt. + * + * @example Server with workflow instructions + * {@includeCode ./examples/InitializeResult/with-instructions.json} + */ + instructions?: string; +} + +/** + * A successful response from the server for a {@link InitializeRequest | initialize} request. + * + * @example Initialize result response + * {@includeCode ./examples/InitializeResultResponse/initialize-result-response.json} + * + * @category `initialize` + */ +export interface InitializeResultResponse extends JSONRPCResultResponse { + result: InitializeResult; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @example Initialized notification + * {@includeCode ./examples/InitializedNotification/initialized-notification.json} + * + * @category `notifications/initialized` + */ +export interface InitializedNotification extends JSONRPCNotification { + method: 'notifications/initialized'; + params?: NotificationParams; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: JSONObject }; + /** + * Present if the client supports listing roots. + * + * @example Roots — minimum baseline support + * {@includeCode ./examples/ClientCapabilities/roots-minimum-baseline-support.json} + * + * @example Roots — list changed notifications + * {@includeCode ./examples/ClientCapabilities/roots-list-changed-notifications.json} + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + * + * @example Sampling — minimum baseline support + * {@includeCode ./examples/ClientCapabilities/sampling-minimum-baseline-support.json} + * + * @example Sampling — tool use support + * {@includeCode ./examples/ClientCapabilities/sampling-tool-use-support.json} + * + * @example Sampling — context inclusion support (soft-deprecated) + * {@includeCode ./examples/ClientCapabilities/sampling-context-inclusion-support-soft-deprecated.json} + */ + sampling?: { + /** + * Whether the client supports context inclusion via `includeContext` parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: JSONObject; + /** + * Whether the client supports tool use via `tools` and `toolChoice` parameters. + */ + tools?: JSONObject; + }; + /** + * Present if the client supports elicitation from the server. + * + * @example Elicitation — form and URL mode support + * {@includeCode ./examples/ClientCapabilities/elicitation-form-and-url-mode-support.json} + * + * @example Elicitation — form mode only (implicit) + * {@includeCode ./examples/ClientCapabilities/elicitation-form-only-implicit.json} + */ + elicitation?: { + form?: JSONObject; + url?: JSONObject; + }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports {@link ListTasksRequest | tasks/list}. + */ + list?: JSONObject; + /** + * Whether this client supports {@link CancelTaskRequest | tasks/cancel}. + */ + cancel?: JSONObject; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for sampling-related requests. + */ + sampling?: { + /** + * Whether the client supports task-augmented `sampling/createMessage` requests. + */ + createMessage?: JSONObject; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented {@link ElicitRequest | elicitation/create} requests. + */ + create?: JSONObject; + }; + }; + }; + /** + * Optional MCP extensions that the client supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/oauth-client-credentials"), and values are + * per-extension settings objects. An empty object indicates support with no settings. + * + * @example Extensions — UI extension with MIME type support + * {@includeCode ./examples/ClientCapabilities/extensions-ui-mime-types.json} + */ + extensions?: { [key: string]: JSONObject }; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: JSONObject }; + /** + * Present if the server supports sending log messages to the client. + * + * @example Logging — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/logging-minimum-baseline-support.json} + */ + logging?: JSONObject; + /** + * Present if the server supports argument autocompletion suggestions. + * + * @example Completions — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/completions-minimum-baseline-support.json} + */ + completions?: JSONObject; + /** + * Present if the server offers any prompt templates. + * + * @example Prompts — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/prompts-minimum-baseline-support.json} + * + * @example Prompts — list changed notifications + * {@includeCode ./examples/ServerCapabilities/prompts-list-changed-notifications.json} + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + * + * @example Resources — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/resources-minimum-baseline-support.json} + * + * @example Resources — subscription to individual resource updates (only) + * {@includeCode ./examples/ServerCapabilities/resources-subscription-to-individual-resource-updates-only.json} + * + * @example Resources — list changed notifications (only) + * {@includeCode ./examples/ServerCapabilities/resources-list-changed-notifications-only.json} + * + * @example Resources — all notifications + * {@includeCode ./examples/ServerCapabilities/resources-all-notifications.json} + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + * + * @example Tools — minimum baseline support + * {@includeCode ./examples/ServerCapabilities/tools-minimum-baseline-support.json} + * + * @example Tools — list changed notifications + * {@includeCode ./examples/ServerCapabilities/tools-list-changed-notifications.json} + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + /** + * Present if the server supports task-augmented requests. + */ + tasks?: { + /** + * Whether this server supports {@link ListTasksRequest | tasks/list}. + */ + list?: JSONObject; + /** + * Whether this server supports {@link CancelTaskRequest | tasks/cancel}. + */ + cancel?: JSONObject; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented {@link CallToolRequest | tools/call} requests. + */ + call?: JSONObject; + }; + }; + }; + /** + * Optional MCP extensions that the server supports. Keys are extension identifiers + * (e.g., "io.modelcontextprotocol/apps"), and values are per-extension settings + * objects. An empty object indicates support with no settings. + * + * @example Extensions — UI extension support + * {@includeCode ./examples/ServerCapabilities/extensions-ui.json} + */ + extensions?: { [key: string]: JSONObject }; +} + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD take steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + * + * @format uri + */ + src: string; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + mimeType?: string; + + /** + * 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. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `"light"` indicates + * the icon is designed to be used with a light background, and `"dark"` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: 'light' | 'dark'; +} + +/** + * Base interface to add `icons` property. + * + * @internal + */ +export interface Icons { + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons?: Icon[]; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for {@link Tool}, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the MCP implementation. + * + * @category `initialize` + */ +export interface Implementation extends BaseMetadata, Icons { + /** + * The version of this implementation. + */ + version: string; + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description?: string; + + /** + * An optional URL of the website for this implementation. + * + * @format uri + */ + websiteUrl?: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + * + * @example Ping request + * {@includeCode ./examples/PingRequest/ping-request.json} + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: 'ping'; + params?: RequestParams; +} + +/** + * A successful response for a {@link PingRequest | ping} request. + * + * @example Ping result response + * {@includeCode ./examples/PingResultResponse/ping-result-response.json} + * + * @category `ping` + */ +export interface PingResultResponse extends JSONRPCResultResponse { + result: EmptyResult; +} + +/* Progress notifications */ + +/** + * Parameters for a {@link ProgressNotification | notifications/progress} notification. + * + * @example Progress message + * {@includeCode ./examples/ProgressNotificationParams/progress-message.json} + * + * @category `notifications/progress` + */ +export interface ProgressNotificationParams extends NotificationParams { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @example Progress message + * {@includeCode ./examples/ProgressNotification/progress-message.json} + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: 'notifications/progress'; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common params for paginated requests. + * + * @example List request with cursor + * {@includeCode ./examples/PaginatedRequestParams/list-with-cursor.json} + * + * @category Common Types + */ +export interface PaginatedRequestParams extends RequestParams { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; +} + +/** @internal */ +export interface PaginatedRequest extends JSONRPCRequest { + params?: PaginatedRequestParams; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @example List resources request + * {@includeCode ./examples/ListResourcesRequest/list-resources-request.json} + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: 'resources/list'; +} + +/** + * The result returned by the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example Resources list with cursor + * {@includeCode ./examples/ListResourcesResult/resources-list-with-cursor.json} + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * A successful response from the server for a {@link ListResourcesRequest | resources/list} request. + * + * @example List resources result response + * {@includeCode ./examples/ListResourcesResultResponse/list-resources-result-response.json} + * + * @category `resources/list` + */ +export interface ListResourcesResultResponse extends JSONRPCResultResponse { + result: ListResourcesResult; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @example List resource templates request + * {@includeCode ./examples/ListResourceTemplatesRequest/list-resource-templates-request.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: 'resources/templates/list'; +} + +/** + * The result returned by the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example Resource templates list + * {@includeCode ./examples/ListResourceTemplatesResult/resource-templates-list.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * A successful response from the server for a {@link ListResourceTemplatesRequest | resources/templates/list} request. + * + * @example List resource templates result response + * {@includeCode ./examples/ListResourceTemplatesResultResponse/list-resource-templates-result-response.json} + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResultResponse extends JSONRPCResultResponse { + result: ListResourceTemplatesResult; +} + +/** + * Common params for resource-related requests. + * + * @internal + */ +export interface ResourceRequestParams extends RequestParams { + /** + * The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; +} + +/** + * Parameters for a `resources/read` request. + * + * @category `resources/read` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ReadResourceRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @example Read resource request + * {@includeCode ./examples/ReadResourceRequest/read-resource-request.json} + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: 'resources/read'; + params: ReadResourceRequestParams; +} + +/** + * The result returned by the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example File resource contents + * {@includeCode ./examples/ReadResourceResult/file-resource-contents.json} + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * A successful response from the server for a {@link ReadResourceRequest | resources/read} request. + * + * @example Read resource result response + * {@includeCode ./examples/ReadResourceResultResponse/read-resource-result-response.json} + * + * @category `resources/read` + */ +export interface ReadResourceResultResponse extends JSONRPCResultResponse { + result: ReadResourceResult; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * + * @example Resources list changed + * {@includeCode ./examples/ResourceListChangedNotification/resources-list-changed.json} + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: 'notifications/resources/list_changed'; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @example Subscribe to file resource + * {@includeCode ./examples/SubscribeRequestParams/subscribe-to-file-resource.json} + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request {@link ResourceUpdatedNotification | resources/updated} notifications from the server whenever a particular resource changes. + * + * @example Subscribe request + * {@includeCode ./examples/SubscribeRequest/subscribe-request.json} + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: 'resources/subscribe'; + params: SubscribeRequestParams; +} + +/** + * A successful response from the server for a {@link SubscribeRequest | resources/subscribe} request. + * + * @example Subscribe result response + * {@includeCode ./examples/SubscribeResultResponse/subscribe-result-response.json} + * + * @category `resources/subscribe` + */ +export interface SubscribeResultResponse extends JSONRPCResultResponse { + result: EmptyResult; +} + +/** + * Parameters for a `resources/unsubscribe` request. + * + * @category `resources/unsubscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UnsubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request cancellation of {@link ResourceUpdatedNotification | resources/updated} notifications from the server. This should follow a previous {@link SubscribeRequest | resources/subscribe} request. + * + * @example Unsubscribe request + * {@includeCode ./examples/UnsubscribeRequest/unsubscribe-request.json} + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: 'resources/unsubscribe'; + params: UnsubscribeRequestParams; +} + +/** + * A successful response from the server for a {@link UnsubscribeRequest | resources/unsubscribe} request. + * + * @example Unsubscribe result response + * {@includeCode ./examples/UnsubscribeResultResponse/unsubscribe-result-response.json} + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeResultResponse extends JSONRPCResultResponse { + result: EmptyResult; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @example File resource updated + * {@includeCode ./examples/ResourceUpdatedNotificationParams/file-resource-updated.json} + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotificationParams extends NotificationParams { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a {@link SubscribeRequest | resources/subscribe} request. + * + * @example File resource updated notification + * {@includeCode ./examples/ResourceUpdatedNotification/file-resource-updated-notification.json} + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends JSONRPCNotification { + method: 'notifications/resources/updated'; + params: ResourceUpdatedNotificationParams; +} + +/** + * A known resource that the server is capable of reading. + * + * @example File resource with annotations + * {@includeCode ./examples/Resource/file-resource-with-annotations.json} + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata, Icons { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + _meta?: MetaObject; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata, Icons { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + _meta?: MetaObject; +} + +/** + * @example Text file contents + * {@includeCode ./examples/TextResourceContents/text-file-contents.json} + * + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @example Image file contents + * {@includeCode ./examples/BlobResourceContents/image-file-contents.json} + * + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @example List prompts request + * {@includeCode ./examples/ListPromptsRequest/list-prompts-request.json} + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: 'prompts/list'; +} + +/** + * The result returned by the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example Prompts list with cursor + * {@includeCode ./examples/ListPromptsResult/prompts-list-with-cursor.json} + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * A successful response from the server for a {@link ListPromptsRequest | prompts/list} request. + * + * @example List prompts result response + * {@includeCode ./examples/ListPromptsResultResponse/list-prompts-result-response.json} + * + * @category `prompts/list` + */ +export interface ListPromptsResultResponse extends JSONRPCResultResponse { + result: ListPromptsResult; +} + +/** + * Parameters for a `prompts/get` request. + * + * @example Get code review prompt + * {@includeCode ./examples/GetPromptRequestParams/get-code-review-prompt.json} + * + * @category `prompts/get` + */ +export interface GetPromptRequestParams extends RequestParams { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @example Get prompt request + * {@includeCode ./examples/GetPromptRequest/get-prompt-request.json} + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: 'prompts/get'; + params: GetPromptRequestParams; +} + +/** + * The result returned by the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Code review prompt + * {@includeCode ./examples/GetPromptResult/code-review-prompt.json} + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A successful response from the server for a {@link GetPromptRequest | prompts/get} request. + * + * @example Get prompt result response + * {@includeCode ./examples/GetPromptResultResponse/get-prompt-result-response.json} + * + * @category `prompts/get` + */ +export interface GetPromptResultResponse extends JSONRPCResultResponse { + result: GetPromptResult; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata, Icons { + /** + * An optional description of what this prompt provides + */ + description?: string; + + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + _meta?: MetaObject; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = 'user' | 'assistant'; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to {@link SamplingMessage}, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of {@link ListResourcesRequest | resources/list} requests. + * + * @example File resource link + * {@includeCode ./examples/ResourceLink/file-resource-link.json} + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: 'resource_link'; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @example Embedded file resource with annotations + * {@includeCode ./examples/EmbeddedResource/embedded-file-resource-with-annotations.json} + * + * @category Content + */ +export interface EmbeddedResource { + type: 'resource'; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @example Prompts list changed + * {@includeCode ./examples/PromptListChangedNotification/prompts-list-changed.json} + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends JSONRPCNotification { + method: 'notifications/prompts/list_changed'; + params?: NotificationParams; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @example List tools request + * {@includeCode ./examples/ListToolsRequest/list-tools-request.json} + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: 'tools/list'; +} + +/** + * The result returned by the server for a {@link ListToolsRequest | tools/list} request. + * + * @example Tools list with cursor + * {@includeCode ./examples/ListToolsResult/tools-list-with-cursor.json} + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * A successful response from the server for a {@link ListToolsRequest | tools/list} request. + * + * @example List tools result response + * {@includeCode ./examples/ListToolsResultResponse/list-tools-result-response.json} + * + * @category `tools/list` + */ +export interface ListToolsResultResponse extends JSONRPCResultResponse { + result: ListToolsResult; +} + +/** + * The result returned by the server for a {@link CallToolRequest | tools/call} request. + * + * @example Result with unstructured text + * {@includeCode ./examples/CallToolResult/result-with-unstructured-text.json} + * + * @example Result with structured content + * {@includeCode ./examples/CallToolResult/result-with-structured-content.json} + * + * @example Invalid tool input error + * {@includeCode ./examples/CallToolResult/invalid-tool-input-error.json} + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON object that represents the structured result of the tool call. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * A successful response from the server for a {@link CallToolRequest | tools/call} request. + * + * @example Call tool result response + * {@includeCode ./examples/CallToolResultResponse/call-tool-result-response.json} + * + * @category `tools/call` + */ +export interface CallToolResultResponse extends JSONRPCResultResponse { + result: CallToolResult; +} + +/** + * Parameters for a `tools/call` request. + * + * @example `get_weather` tool call params + * {@includeCode ./examples/CallToolRequestParams/get-weather-tool-call-params.json} + * + * @example Tool call params with progress token + * {@includeCode ./examples/CallToolRequestParams/tool-call-params-with-progress-token.json} + * + * @category `tools/call` + */ +export interface CallToolRequestParams extends TaskAugmentedRequestParams { + /** + * The name of the tool. + */ + name: string; + /** + * Arguments to use for the tool call. + */ + arguments?: { [key: string]: unknown }; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @example Call tool request + * {@includeCode ./examples/CallToolRequest/call-tool-request.json} + * + * @category `tools/call` + */ +export interface CallToolRequest extends JSONRPCRequest { + method: 'tools/call'; + params: CallToolRequestParams; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @example Tools list changed + * {@includeCode ./examples/ToolListChangedNotification/tools-list-changed.json} + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: 'notifications/tools/list_changed'; + params?: NotificationParams; +} + +/** + * Additional properties describing a {@link Tool} to clients. + * + * NOTE: all properties in `ToolAnnotations` are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on `ToolAnnotations` + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * 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. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Execution-related properties for a tool. + * + * @category `tools/list` + */ +export interface ToolExecution { + /** + * Indicates whether this tool supports task-augmented execution. + * This allows clients to handle long-running operations through polling + * the task system. + * + * - `"forbidden"`: Tool does not support task-augmented execution (default when absent) + * - `"optional"`: Tool may support task-augmented execution + * - `"required"`: Tool requires task-augmented execution + * + * Default: `"forbidden"` + */ + taskSupport?: 'forbidden' | 'optional' | 'required'; +} + +/** + * Definition for a tool the client can call. + * + * @example With default 2020-12 input schema + * {@includeCode ./examples/Tool/with-default-2020-12-input-schema.json} + * + * @example With explicit draft-07 input schema + * {@includeCode ./examples/Tool/with-explicit-draft-07-input-schema.json} + * + * @example With no parameters + * {@includeCode ./examples/Tool/with-no-parameters.json} + * + * @example With output schema for structured content + * {@includeCode ./examples/Tool/with-output-schema-for-structured-content.json} + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata, Icons { + /** + * 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. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + $schema?: string; + type: 'object'; + properties?: { [key: string]: JSONValue }; + required?: string[]; + }; + + /** + * Execution-related properties for this tool. + */ + execution?: ToolExecution; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a {@link CallToolResult}. + * + * Defaults to JSON Schema 2020-12 when no explicit `$schema` is provided. + * Currently restricted to `type: "object"` at the root level. + */ + outputSchema?: { + $schema?: string; + type: 'object'; + properties?: { [key: string]: JSONValue }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: `title`, `annotations.title`, then `name`. + */ + annotations?: ToolAnnotations; + + _meta?: MetaObject; +} + +/* Tasks */ + +/** + * The status of a task. + * + * @category `tasks` + */ +export type TaskStatus = + | 'working' // The request is currently being processed + | 'input_required' // The task is waiting for input (e.g., elicitation or sampling) + | 'completed' // The request completed successfully and results are available + | 'failed' // The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true. + | 'cancelled'; // The request was cancelled before completion + +/** + * Metadata for augmenting a request with task execution. + * Include this in the `task` field of the request parameters. + * + * @category `tasks` + */ +export interface TaskMetadata { + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl?: number; +} + +/** + * Metadata for associating messages with a task. + * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @category `tasks` + */ +export interface RelatedTaskMetadata { + /** + * The task identifier this message is associated with. + */ + taskId: string; +} + +/** + * Data associated with a task. + * + * @category `tasks` + */ +export interface Task { + /** + * The task identifier. + */ + taskId: string; + + /** + * Current task state. + */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + */ + statusMessage?: string; + + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string; + + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + * @nullable + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * The result returned for a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * A successful response for a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResultResponse extends JSONRPCResultResponse { + result: CreateTaskResult; +} + +/** + * A request to retrieve the state of a task. + * + * @category `tasks/get` + */ +export interface GetTaskRequest extends JSONRPCRequest { + method: 'tasks/get'; + params: { + /** + * The task identifier to query. + */ + taskId: string; + }; +} + +/** + * The result returned for a {@link GetTaskRequest | tasks/get} request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * A successful response for a {@link GetTaskRequest | tasks/get} request. + * + * @category `tasks/get` + */ +export interface GetTaskResultResponse extends JSONRPCResultResponse { + result: GetTaskResult; +} + +/** + * A request to retrieve the result of a completed task. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadRequest extends JSONRPCRequest { + method: 'tasks/result'; + params: { + /** + * The task identifier to retrieve results for. + */ + taskId: string; + }; +} + +/** + * The result returned for a {@link GetTaskPayloadRequest | tasks/result} request. + * The structure matches the result type of the original request. + * For example, a {@link CallToolRequest | tools/call} task would return the {@link CallToolResult} structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * A successful response for a {@link GetTaskPayloadRequest | tasks/result} request. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResultResponse extends JSONRPCResultResponse { + result: GetTaskPayloadResult; +} + +/** + * A request to cancel a task. + * + * @category `tasks/cancel` + */ +export interface CancelTaskRequest extends JSONRPCRequest { + method: 'tasks/cancel'; + params: { + /** + * The task identifier to cancel. + */ + taskId: string; + }; +} + +/** + * The result returned for a {@link CancelTaskRequest | tasks/cancel} request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A successful response for a {@link CancelTaskRequest | tasks/cancel} request. + * + * @category `tasks/cancel` + */ +export interface CancelTaskResultResponse extends JSONRPCResultResponse { + result: CancelTaskResult; +} + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: 'tasks/list'; +} + +/** + * The result returned for a {@link ListTasksRequest | tasks/list} request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * A successful response for a {@link ListTasksRequest | tasks/list} request. + * + * @category `tasks/list` + */ +export interface ListTasksResultResponse extends JSONRPCResultResponse { + result: ListTasksResult; +} + +/** + * Parameters for a `notifications/tasks/status` notification. + * + * @category `notifications/tasks/status` + */ +export type TaskStatusNotificationParams = NotificationParams & Task; + +/** + * An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. + * + * @category `notifications/tasks/status` + */ +export interface TaskStatusNotification extends JSONRPCNotification { + method: 'notifications/tasks/status'; + params: TaskStatusNotificationParams; +} + +/* Logging */ + +/** + * Parameters for a `logging/setLevel` request. + * + * @example Set log level to "info" + * {@includeCode ./examples/SetLevelRequestParams/set-log-level-to-info.json} + * + * @category `logging/setLevel` + */ +export interface SetLevelRequestParams extends RequestParams { + /** + * 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 {@link LoggingMessageNotification | notifications/message}. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @example Set logging level request + * {@includeCode ./examples/SetLevelRequest/set-logging-level-request.json} + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: 'logging/setLevel'; + params: SetLevelRequestParams; +} + +/** + * A successful response from the server for a {@link SetLevelRequest | logging/setLevel} request. + * + * @example Set logging level result response + * {@includeCode ./examples/SetLevelResultResponse/set-logging-level-result-response.json} + * + * @category `logging/setLevel` + */ +export interface SetLevelResultResponse extends JSONRPCResultResponse { + result: EmptyResult; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotificationParams/log-database-connection-failed.json} + * + * @category `notifications/message` + */ +export interface LoggingMessageNotificationParams extends NotificationParams { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; +} + +/** + * JSONRPCNotification of a log message passed from server to client. If no `logging/setLevel` request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @example Log database connection failed + * {@includeCode ./examples/LoggingMessageNotification/log-database-connection-failed.json} + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends JSONRPCNotification { + method: 'notifications/message'; + params: LoggingMessageNotificationParams; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types + */ +export type LoggingLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency'; + +/* Sampling */ +/** + * Parameters for a `sampling/createMessage` request. + * + * @example Basic request + * {@includeCode ./examples/CreateMessageRequestParams/basic-request.json} + * + * @example Request with tools + * {@includeCode ./examples/CreateMessageRequestParams/request-with-tools.json} + * + * @example Follow-up request with tool results + * {@includeCode ./examples/CreateMessageRequestParams/follow-up-with-tool-results.json} + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is `"none"`. Values `"thisServer"` and `"allServers"` are soft-deprecated. Servers SHOULD only use these values if the client + * declares {@link ClientCapabilities.sampling.context}. These values may be removed in future spec releases. + */ + includeContext?: 'none' | 'thisServer' | 'allServers'; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: JSONObject; + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + */ + tools?: Tool[]; + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but {@link ClientCapabilities.sampling.tools} is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice?: ToolChoice; +} + +/** + * Controls tool selection behavior for sampling requests. + * + * @category `sampling/createMessage` + */ +export interface ToolChoice { + /** + * Controls the tool use ability of the model: + * - `"auto"`: Model decides whether to use tools (default) + * - `"required"`: Model MUST use at least one tool before completing + * - `"none"`: Model MUST NOT use any tools + */ + mode?: 'auto' | 'required' | 'none'; +} + +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @example Sampling request + * {@includeCode ./examples/CreateMessageRequest/sampling-request.json} + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: 'sampling/createMessage'; + params: CreateMessageRequestParams; +} + +/** + * The result returned by the client for a {@link CreateMessageRequest | sampling/createMessage} request. + * The client should inform the user before returning the sampled message, to allow them + * to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @example Text response + * {@includeCode ./examples/CreateMessageResult/text-response.json} + * + * @example Tool use response + * {@includeCode ./examples/CreateMessageResult/tool-use-response.json} + * + * @example Final response after tool use + * {@includeCode ./examples/CreateMessageResult/final-response.json} + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - `"endTurn"`: Natural end of the assistant's turn + * - `"stopSequence"`: A stop sequence was encountered + * - `"maxTokens"`: Maximum token limit was reached + * - `"toolUse"`: The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason?: 'endTurn' | 'stopSequence' | 'maxTokens' | 'toolUse' | string; +} + +/** + * A successful response from the client for a {@link CreateMessageRequest | sampling/createMessage} request. + * + * @example Sampling result response + * {@includeCode ./examples/CreateMessageResultResponse/sampling-result-response.json} + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResultResponse extends JSONRPCResultResponse { + result: CreateMessageResult; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @example Single content block + * {@includeCode ./examples/SamplingMessage/single-content-block.json} + * + * @example Multiple content blocks + * {@includeCode ./examples/SamplingMessage/multiple-content-blocks.json} + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + _meta?: MetaObject; +} + +/** + * @category `sampling/createMessage` + */ +export type SamplingMessageContentBlock = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent; + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended audience of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * 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. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @example Text content + * {@includeCode ./examples/TextContent/text-content.json} + * + * @category Content + */ +export interface TextContent { + type: 'text'; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * An image provided to or from an LLM. + * + * @example `image/png` content with annotations + * {@includeCode ./examples/ImageContent/image-png-content-with-annotations.json} + * + * @category Content + */ +export interface ImageContent { + type: 'image'; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * Audio provided to or from an LLM. + * + * @example `audio/wav` content + * {@includeCode ./examples/AudioContent/audio-wav-content.json} + * + * @category Content + */ +export interface AudioContent { + type: 'audio'; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + _meta?: MetaObject; +} + +/** + * A request from the assistant to call a tool. + * + * @example `get_weather` tool use + * {@includeCode ./examples/ToolUseContent/get-weather-tool-use.json} + * + * @category `sampling/createMessage` + */ +export interface ToolUseContent { + type: 'tool_use'; + + /** + * A unique identifier for this tool use. + * + * This ID is used to match tool results to their corresponding tool uses. + */ + id: string; + + /** + * The name of the tool to call. + */ + name: string; + + /** + * The arguments to pass to the tool, conforming to the tool's input schema. + */ + input: { [key: string]: unknown }; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + */ + _meta?: MetaObject; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @example `get_weather` tool result + * {@includeCode ./examples/ToolResultContent/get-weather-tool-result.json} + * + * @category `sampling/createMessage` + */ +export interface ToolResultContent { + type: 'tool_result'; + + /** + * The ID of the tool use this result corresponds to. + * + * This MUST match the ID from a previous {@link ToolUseContent}. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as {@link CallToolResult.content} and can include text, images, + * audio, resource links, and embedded resources. + */ + content: ContentBlock[]; + + /** + * An optional structured result object. + * + * If the tool defined an {@link Tool.outputSchema}, this SHOULD conform to that schema. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + */ + isError?: boolean; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + */ + _meta?: MetaObject; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @example With hints and priorities + * {@includeCode ./examples/ModelPreferences/with-hints-and-priorities.json} + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * 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. + */ + hints?: ModelHint[]; + + /** + * 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. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * 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. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * 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. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * 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` + */ + name?: string; +} + +/* Autocomplete */ +/** + * Parameters for a `completion/complete` request. + * + * @category `completion/complete` + * + * @example Prompt argument completion + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion.json} + * + * @example Prompt argument completion with context + * {@includeCode ./examples/CompleteRequestParams/prompt-argument-completion-with-context.json} + */ +export interface CompleteRequestParams extends RequestParams { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * A request from the client to the server, to ask for completion options. + * + * @example Completion request + * {@includeCode ./examples/CompleteRequest/completion-request.json} + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: 'completion/complete'; + params: CompleteRequestParams; +} + +/** + * The result returned by the server for a {@link CompleteRequest | completion/complete} request. + * + * @category `completion/complete` + * + * @example Single completion value + * {@includeCode ./examples/CompleteResult/single-completion-value.json} + * + * @example Multiple completion values with more available + * {@includeCode ./examples/CompleteResult/multiple-completion-values-with-more-available.json} + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + * + * @maxItems 100 + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A successful response from the server for a {@link CompleteRequest | completion/complete} request. + * + * @example Completion result response + * {@includeCode ./examples/CompleteResultResponse/completion-result-response.json} + * + * @category `completion/complete` + */ +export interface CompleteResultResponse extends JSONRPCResultResponse { + result: CompleteResult; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: 'ref/resource'; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: 'ref/prompt'; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @example List roots request + * {@includeCode ./examples/ListRootsRequest/list-roots-request.json} + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: 'roots/list'; + params?: RequestParams; +} + +/** + * The result returned by the client for a {@link ListRootsRequest | roots/list} request. + * This result contains an array of {@link Root} objects, each representing a root directory + * or file that the server can operate on. + * + * @example Single root directory + * {@includeCode ./examples/ListRootsResult/single-root-directory.json} + * + * @example Multiple root directories + * {@includeCode ./examples/ListRootsResult/multiple-root-directories.json} + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * A successful response from the client for a {@link ListRootsRequest | roots/list} request. + * + * @example List roots result response + * {@includeCode ./examples/ListRootsResultResponse/list-roots-result-response.json} + * + * @category `roots/list` + */ +export interface ListRootsResultResponse extends JSONRPCResultResponse { + result: ListRootsResult; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @example Project directory root + * {@includeCode ./examples/Root/project-directory.json} + * + * @category `roots/list` + */ +export interface Root { + /** + * 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. + * + * @format uri + */ + uri: string; + /** + * 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. + */ + name?: string; + + _meta?: MetaObject; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the {@link ListRootsRequest}. + * + * @example Roots list changed + * {@includeCode ./examples/RootsListChangedNotification/roots-list-changed.json} + * + * @category `notifications/roots/list_changed` + */ +export interface RootsListChangedNotification extends JSONRPCNotification { + method: 'notifications/roots/list_changed'; + params?: NotificationParams; +} + +/** + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. + * + * @example Elicit single field + * {@includeCode ./examples/ElicitRequestFormParams/elicit-single-field.json} + * + * @example Elicit multiple fields + * {@includeCode ./examples/ElicitRequestFormParams/elicit-multiple-fields.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode?: 'form'; + + /** + * The message to present to the user describing what information is being requested. + */ + message: string; + + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + $schema?: string; + type: 'object'; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; +} + +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @example Elicit sensitive data + * {@includeCode ./examples/ElicitRequestURLParams/elicit-sensitive-data.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode: 'url'; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = ElicitRequestFormParams | ElicitRequestURLParams; + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @example Elicitation request + * {@includeCode ./examples/ElicitRequest/elicitation-request.json} + * + * @category `elicitation/create` + */ +export interface ElicitRequest extends JSONRPCRequest { + method: 'elicitation/create'; + params: ElicitRequestParams; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = StringSchema | NumberSchema | BooleanSchema | EnumSchema; + +/** + * @example Email input schema + * {@includeCode ./examples/StringSchema/email-input-schema.json} + * + * @category `elicitation/create` + */ +export interface StringSchema { + type: 'string'; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: 'email' | 'uri' | 'date' | 'date-time'; + default?: string; +} + +/** + * @example Number input schema + * {@includeCode ./examples/NumberSchema/number-input-schema.json} + * + * @category `elicitation/create` + */ +export interface NumberSchema { + type: 'number' | 'integer'; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @example Boolean input schema + * {@includeCode ./examples/BooleanSchema/boolean-input-schema.json} + * + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: 'boolean'; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @example Color select schema + * {@includeCode ./examples/UntitledSingleSelectEnumSchema/color-select-schema.json} + * + * @category `elicitation/create` + */ +export interface UntitledSingleSelectEnumSchema { + type: 'string'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum values to choose from. + */ + enum: string[]; + /** + * Optional default value. + */ + default?: string; +} + +/** + * Schema for single-selection enumeration with display titles for each option. + * + * @example Titled color select schema + * {@includeCode ./examples/TitledSingleSelectEnumSchema/titled-color-select-schema.json} + * + * @category `elicitation/create` + */ +export interface TitledSingleSelectEnumSchema { + type: 'string'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Combined single selection enumeration +export type SingleSelectEnumSchema = UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + * + * @example Color multi-select schema + * {@includeCode ./examples/UntitledMultiSelectEnumSchema/color-multi-select-schema.json} + * + * @category `elicitation/create` + */ +export interface UntitledMultiSelectEnumSchema { + type: 'array'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: 'string'; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @example Titled color multi-select schema + * {@includeCode ./examples/TitledMultiSelectEnumSchema/titled-color-multi-select-schema.json} + * + * @category `elicitation/create` + */ +export interface TitledMultiSelectEnumSchema { + type: 'array'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * @category `elicitation/create` + */ +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; + +/** + * Use {@link TitledSingleSelectEnumSchema} instead. + * This interface will be removed in a future version. + * + * @category `elicitation/create` + */ +export interface LegacyTitledEnumSchema { + type: 'string'; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Union type for all enum schemas +export type EnumSchema = SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; + +/** + * The result returned by the client for an {@link ElicitRequest | elicitation/create} request. + * + * @example Input single field + * {@includeCode ./examples/ElicitResult/input-single-field.json} + * + * @example Input multiple fields + * {@includeCode ./examples/ElicitResult/input-multiple-fields.json} + * + * @example Accept URL mode (no content) + * {@includeCode ./examples/ElicitResult/accept-url-mode-no-content.json} + * + * @category `elicitation/create` + */ +export interface ElicitResult extends Result { + /** + * The user action in response to the elicitation. + * - `"accept"`: User submitted the form/confirmed the action + * - `"decline"`: User explicitly declined the action + * - `"cancel"`: User dismissed without making an explicit choice + */ + action: 'accept' | 'decline' | 'cancel'; + + /** + * The submitted form data, only present when action is `"accept"` and mode was `"form"`. + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + */ + content?: { [key: string]: string | number | boolean | string[] }; +} + +/** + * A successful response from the client for a {@link ElicitRequest | elicitation/create} request. + * + * @example Elicitation result response + * {@includeCode ./examples/ElicitResultResponse/elicitation-result-response.json} + * + * @category `elicitation/create` + */ +export interface ElicitResultResponse extends JSONRPCResultResponse { + result: ElicitResult; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @example Elicitation complete + * {@includeCode ./examples/ElicitationCompleteNotification/elicitation-complete.json} + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotification extends JSONRPCNotification { + method: 'notifications/elicitation/complete'; + params: { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; + }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | TaskStatusNotification; + +/** @internal */ +export type ClientResult = + | EmptyResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; + +/* Server messages */ +/** @internal */ +export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitationCompleteNotification + | TaskStatusNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | CreateTaskResult + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/packages/core/src/types/specTypeSchema.examples.ts b/packages/core/src/types/specTypeSchema.examples.ts new file mode 100644 index 0000000..8e991d4 --- /dev/null +++ b/packages/core/src/types/specTypeSchema.examples.ts @@ -0,0 +1,40 @@ +/** + * Type-checked examples for `specTypeSchema.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { isSpecType, specTypeSchemas } from './specTypeSchema.js'; + +declare const untrusted: unknown; +declare const value: unknown; +declare const mixed: unknown[]; + +function specTypeSchemas_basicUsage() { + //#region specTypeSchemas_basicUsage + const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); + if (result.issues === undefined) { + // result.value is CallToolResult + } + //#endregion specTypeSchemas_basicUsage + void result; +} + +function isSpecType_basicUsage() { + /* eslint-disable unicorn/no-array-callback-reference -- showcasing the guard-as-callback pattern */ + //#region isSpecType_basicUsage + if (isSpecType.ContentBlock(value)) { + // value is ContentBlock + } + + const blocks = mixed.filter(isSpecType.ContentBlock); + //#endregion isSpecType_basicUsage + /* eslint-enable unicorn/no-array-callback-reference */ + void blocks; +} + +void specTypeSchemas_basicUsage; +void isSpecType_basicUsage; diff --git a/packages/core/src/types/specTypeSchema.ts b/packages/core/src/types/specTypeSchema.ts new file mode 100644 index 0000000..477d61a --- /dev/null +++ b/packages/core/src/types/specTypeSchema.ts @@ -0,0 +1,296 @@ +import type * as z from 'zod/v4'; + +import { + OAuthClientInformationFullSchema, + OAuthClientInformationSchema, + OAuthClientMetadataSchema, + OAuthClientRegistrationErrorSchema, + OAuthErrorResponseSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokenRevocationRequestSchema, + OAuthTokensSchema, + OpenIdProviderDiscoveryMetadataSchema, + OpenIdProviderMetadataSchema +} from '../shared/auth.js'; +import type { StandardSchemaV1, StandardSchemaV1Sync } from '../util/standardSchema.js'; +import * as schemas from './schemas.js'; + +/** + * Explicit allowlist of protocol Zod schemas that correspond to a public spec type in `types.ts`. + * + * This intentionally excludes internal helper schemas exported from `schemas.ts` that have no + * matching public type (e.g. `ListChangedOptionsBaseSchema`, `BaseRequestParamsSchema`, + * `NotificationsParamsSchema`, `ClientTasksCapabilitySchema`, `ServerTasksCapabilitySchema`). + * Keeping the list explicit means new public spec types must be added here deliberately, and + * internals never leak into `SpecTypeName`. + * + * `ResourceTemplateSchema` is included; its public type is exported as `ResourceTemplateType` + * (the bare name collides with the server package's `ResourceTemplate` class), so + * `SpecTypes['ResourceTemplate']` is structurally equal to `ResourceTemplateType` rather than to + * a type literally named `ResourceTemplate`. + */ +const SPEC_SCHEMA_KEYS = [ + 'AnnotationsSchema', + 'AudioContentSchema', + 'BaseMetadataSchema', + 'BlobResourceContentsSchema', + 'BooleanSchemaSchema', + 'CallToolRequestSchema', + 'CallToolRequestParamsSchema', + 'CallToolResultSchema', + 'CancelledNotificationSchema', + 'CancelledNotificationParamsSchema', + 'CancelTaskRequestSchema', + 'CancelTaskResultSchema', + 'ClientCapabilitiesSchema', + 'ClientNotificationSchema', + 'ClientRequestSchema', + 'ClientResultSchema', + 'CompatibilityCallToolResultSchema', + 'CompleteRequestSchema', + 'CompleteRequestParamsSchema', + 'CompleteResultSchema', + 'ContentBlockSchema', + 'CreateMessageRequestSchema', + 'CreateMessageRequestParamsSchema', + 'CreateMessageResultSchema', + 'CreateMessageResultWithToolsSchema', + 'CreateTaskResultSchema', + 'CursorSchema', + 'ElicitationCompleteNotificationSchema', + 'ElicitationCompleteNotificationParamsSchema', + 'ElicitRequestSchema', + 'ElicitRequestFormParamsSchema', + 'ElicitRequestParamsSchema', + 'ElicitRequestURLParamsSchema', + 'ElicitResultSchema', + 'EmbeddedResourceSchema', + 'EmptyResultSchema', + 'EnumSchemaSchema', + 'GetPromptRequestSchema', + 'GetPromptRequestParamsSchema', + 'GetPromptResultSchema', + 'GetTaskPayloadRequestSchema', + 'GetTaskPayloadResultSchema', + 'GetTaskRequestSchema', + 'GetTaskResultSchema', + 'IconSchema', + 'IconsSchema', + 'ImageContentSchema', + 'ImplementationSchema', + 'InitializedNotificationSchema', + 'InitializeRequestSchema', + 'InitializeRequestParamsSchema', + 'InitializeResultSchema', + 'JSONArraySchema', + 'JSONObjectSchema', + 'JSONRPCErrorResponseSchema', + 'JSONRPCMessageSchema', + 'JSONRPCNotificationSchema', + 'JSONRPCRequestSchema', + 'JSONRPCResponseSchema', + 'JSONRPCResultResponseSchema', + 'JSONValueSchema', + 'LegacyTitledEnumSchemaSchema', + 'ListPromptsRequestSchema', + 'ListPromptsResultSchema', + 'ListResourcesRequestSchema', + 'ListResourcesResultSchema', + 'ListResourceTemplatesRequestSchema', + 'ListResourceTemplatesResultSchema', + 'ListRootsRequestSchema', + 'ListRootsResultSchema', + 'ListTasksRequestSchema', + 'ListTasksResultSchema', + 'ListToolsRequestSchema', + 'ListToolsResultSchema', + 'LoggingLevelSchema', + 'LoggingMessageNotificationSchema', + 'LoggingMessageNotificationParamsSchema', + 'ModelHintSchema', + 'ModelPreferencesSchema', + 'MultiSelectEnumSchemaSchema', + 'NotificationSchema', + 'NumberSchemaSchema', + 'PaginatedRequestSchema', + 'PaginatedRequestParamsSchema', + 'PaginatedResultSchema', + 'PingRequestSchema', + 'PrimitiveSchemaDefinitionSchema', + 'ProgressSchema', + 'ProgressNotificationSchema', + 'ProgressNotificationParamsSchema', + 'ProgressTokenSchema', + 'PromptSchema', + 'PromptArgumentSchema', + 'PromptListChangedNotificationSchema', + 'PromptMessageSchema', + 'PromptReferenceSchema', + 'ReadResourceRequestSchema', + 'ReadResourceRequestParamsSchema', + 'ReadResourceResultSchema', + 'RelatedTaskMetadataSchema', + 'RequestSchema', + 'RequestIdSchema', + 'RequestMetaSchema', + 'ResourceSchema', + 'ResourceContentsSchema', + 'ResourceLinkSchema', + 'ResourceListChangedNotificationSchema', + 'ResourceRequestParamsSchema', + 'ResourceTemplateSchema', + 'ResourceTemplateReferenceSchema', + 'ResourceUpdatedNotificationSchema', + 'ResourceUpdatedNotificationParamsSchema', + 'ResultSchema', + 'RoleSchema', + 'RootSchema', + 'RootsListChangedNotificationSchema', + 'SamplingContentSchema', + 'SamplingMessageSchema', + 'SamplingMessageContentBlockSchema', + 'ServerCapabilitiesSchema', + 'ServerNotificationSchema', + 'ServerRequestSchema', + 'ServerResultSchema', + 'SetLevelRequestSchema', + 'SetLevelRequestParamsSchema', + 'SingleSelectEnumSchemaSchema', + 'StringSchemaSchema', + 'SubscribeRequestSchema', + 'SubscribeRequestParamsSchema', + 'TaskSchema', + 'TaskAugmentedRequestParamsSchema', + 'TaskCreationParamsSchema', + 'TaskMetadataSchema', + 'TaskStatusSchema', + 'TaskStatusNotificationSchema', + 'TaskStatusNotificationParamsSchema', + 'TextContentSchema', + 'TextResourceContentsSchema', + 'TitledMultiSelectEnumSchemaSchema', + 'TitledSingleSelectEnumSchemaSchema', + 'ToolSchema', + 'ToolAnnotationsSchema', + 'ToolChoiceSchema', + 'ToolExecutionSchema', + 'ToolListChangedNotificationSchema', + 'ToolResultContentSchema', + 'ToolUseContentSchema', + 'UnsubscribeRequestSchema', + 'UnsubscribeRequestParamsSchema', + 'UntitledMultiSelectEnumSchemaSchema', + 'UntitledSingleSelectEnumSchemaSchema' +] as const satisfies readonly (keyof typeof schemas)[]; + +const authSchemas = { + OAuthClientInformationFullSchema, + OAuthClientInformationSchema, + OAuthClientMetadataSchema, + OAuthClientRegistrationErrorSchema, + OAuthErrorResponseSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokenRevocationRequestSchema, + OAuthTokensSchema, + OpenIdProviderDiscoveryMetadataSchema, + OpenIdProviderMetadataSchema +} as const; + +type ProtocolSchemaKey = (typeof SPEC_SCHEMA_KEYS)[number]; +type AuthSchemaKey = keyof typeof authSchemas; +type SchemaKey = ProtocolSchemaKey | AuthSchemaKey; + +type SchemaFor = K extends ProtocolSchemaKey + ? (typeof schemas)[K] + : K extends AuthSchemaKey + ? (typeof authSchemas)[K] + : never; + +type StripSchemaSuffix = K extends `${infer N}Schema` ? N : never; + +/** + * Union of every named type in the SDK's protocol and OAuth schemas (e.g. `'CallToolResult'`, + * `'ContentBlock'`, `'Tool'`, `'OAuthTokens'`). Derived from the internal Zod schemas, so it stays + * in sync with the spec. + */ +export type SpecTypeName = StripSchemaSuffix; + +/** + * Maps each {@linkcode SpecTypeName} to its TypeScript type. + * + * `SpecTypes['CallToolResult']` is equivalent to importing the `CallToolResult` type directly. + */ +export type SpecTypes = { + [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.output> : never; +}; + +/** + * Input shape for each {@linkcode SpecTypeName}. For most types this equals {@linkcode SpecTypes}, + * but a few schemas apply defaults/preprocessing, so the accepted input may be looser than the + * resulting output type. + */ +type SpecTypeInputs = { + [K in SchemaKey as StripSchemaSuffix]: SchemaFor extends z.ZodType ? z.input> : never; +}; + +type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync }; +type GuardRecord = { readonly [K in SpecTypeName]: (value: unknown) => value is SpecTypeInputs[K] }; + +const _specTypeSchemas: Record = {}; +const _isSpecType: Record boolean> = {}; +function register(key: string, schema: z.ZodType): void { + const name = key.slice(0, -'Schema'.length); + _specTypeSchemas[name] = schema; + _isSpecType[name] = (v: unknown) => schema.safeParse(v).success; +} +for (const key of SPEC_SCHEMA_KEYS) { + // eslint-disable-next-line import/namespace -- key is constrained to keyof typeof schemas via the satisfies clause above + register(key, schemas[key]); +} +for (const [key, schema] of Object.entries(authSchemas)) { + register(key, schema); +} + +/** + * Runtime validators for every MCP spec type, keyed by type name. + * + * Use this when you need to validate a spec-defined shape at a boundary the SDK does not own, for + * example an extension's custom-method payload that embeds a `CallToolResult`, or a value read from + * storage that should be a `Tool`. + * + * Each entry implements the Standard Schema interface, so it composes with any + * Standard-Schema-aware library. For a simple boolean check, use {@linkcode isSpecType} instead. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#specTypeSchemas_basicUsage" + * const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); + * if (result.issues === undefined) { + * // result.value is CallToolResult + * } + * ``` + */ +export const specTypeSchemas: SchemaRecord = Object.freeze(_specTypeSchemas as SchemaRecord); + +/** + * Type predicates for every MCP spec type, keyed by type name. + * + * Returns `true` if the value satisfies the schema's input type (`z.input<>`, before defaults and + * transforms are applied), and narrows to that input type. For schemas with `.default()` or + * `.preprocess()`, this may accept values that do not structurally match the named output type; + * for example `isSpecType.CallToolResult({})` is `true` because `content` has a default. Use + * `specTypeSchemas.X['~standard'].validate(value)` when you need the validated output value. + * + * Each guard is a standalone function, so it can be passed directly as a callback. + * + * @example + * ```ts source="./specTypeSchema.examples.ts#isSpecType_basicUsage" + * if (isSpecType.ContentBlock(value)) { + * // value is ContentBlock + * } + * + * const blocks = mixed.filter(isSpecType.ContentBlock); + * ``` + */ +export const isSpecType: GuardRecord = Object.freeze(_isSpecType as GuardRecord); diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts new file mode 100644 index 0000000..a92deec --- /dev/null +++ b/packages/core/src/types/types.ts @@ -0,0 +1,562 @@ +// ⚠️ PUBLIC API — every export from this file is re-exported via `export *` +// in exports/public/index.ts and becomes part of the SDK's public surface. +// Only add MCP-spec-derived types here. Internal helpers belong elsewhere. + +import type * as z from 'zod/v4'; + +import type { INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR } from './constants.js'; +import type { + AnnotationsSchema, + AudioContentSchema, + BaseMetadataSchema, + BaseRequestParamsSchema, + BlobResourceContentsSchema, + BooleanSchemaSchema, + CallToolRequestParamsSchema, + CallToolRequestSchema, + CallToolResultSchema, + CancelledNotificationParamsSchema, + CancelledNotificationSchema, + CancelTaskRequestSchema, + CancelTaskResultSchema, + ClientCapabilitiesSchema, + ClientNotificationSchema, + ClientRequestSchema, + ClientResultSchema, + CompatibilityCallToolResultSchema, + CompleteRequestParamsSchema, + CompleteRequestSchema, + CompleteResultSchema, + ContentBlockSchema, + CreateMessageRequestParamsSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, + CursorSchema, + ElicitationCompleteNotificationParamsSchema, + ElicitationCompleteNotificationSchema, + ElicitRequestFormParamsSchema, + ElicitRequestParamsSchema, + ElicitRequestSchema, + ElicitRequestURLParamsSchema, + ElicitResultSchema, + EmbeddedResourceSchema, + EmptyResultSchema, + EnumSchemaSchema, + GetPromptRequestParamsSchema, + GetPromptRequestSchema, + GetPromptResultSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResultSchema, + GetTaskRequestSchema, + GetTaskResultSchema, + IconSchema, + IconsSchema, + ImageContentSchema, + ImplementationSchema, + InitializedNotificationSchema, + InitializeRequestParamsSchema, + InitializeRequestSchema, + InitializeResultSchema, + JSONRPCErrorResponseSchema, + JSONRPCMessageSchema, + JSONRPCNotificationSchema, + JSONRPCRequestSchema, + JSONRPCResponseSchema, + JSONRPCResultResponseSchema, + LegacyTitledEnumSchemaSchema, + ListPromptsRequestSchema, + ListPromptsResultSchema, + ListResourcesRequestSchema, + ListResourcesResultSchema, + ListResourceTemplatesRequestSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, + ListRootsResultSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + LoggingLevelSchema, + LoggingMessageNotificationParamsSchema, + LoggingMessageNotificationSchema, + ModelHintSchema, + ModelPreferencesSchema, + MultiSelectEnumSchemaSchema, + NotificationSchema, + NotificationsParamsSchema, + NumberSchemaSchema, + PaginatedRequestParamsSchema, + PaginatedRequestSchema, + PaginatedResultSchema, + PingRequestSchema, + PrimitiveSchemaDefinitionSchema, + ProgressNotificationParamsSchema, + ProgressNotificationSchema, + ProgressSchema, + ProgressTokenSchema, + PromptArgumentSchema, + PromptListChangedNotificationSchema, + PromptMessageSchema, + PromptReferenceSchema, + PromptSchema, + ReadResourceRequestParamsSchema, + ReadResourceRequestSchema, + ReadResourceResultSchema, + RelatedTaskMetadataSchema, + RequestIdSchema, + RequestMetaSchema, + RequestSchema, + ResourceContentsSchema, + ResourceLinkSchema, + ResourceListChangedNotificationSchema, + ResourceRequestParamsSchema, + ResourceSchema, + ResourceTemplateReferenceSchema, + ResourceTemplateSchema, + ResourceUpdatedNotificationParamsSchema, + ResourceUpdatedNotificationSchema, + ResultSchema, + RoleSchema, + RootSchema, + RootsListChangedNotificationSchema, + SamplingContentSchema, + SamplingMessageContentBlockSchema, + SamplingMessageSchema, + ServerCapabilitiesSchema, + ServerNotificationSchema, + ServerRequestSchema, + ServerResultSchema, + SetLevelRequestParamsSchema, + SetLevelRequestSchema, + SingleSelectEnumSchemaSchema, + StringSchemaSchema, + SubscribeRequestParamsSchema, + SubscribeRequestSchema, + TaskAugmentedRequestParamsSchema, + TaskCreationParamsSchema, + TaskMetadataSchema, + TaskSchema, + TaskStatusNotificationParamsSchema, + TaskStatusNotificationSchema, + TaskStatusSchema, + TextContentSchema, + TextResourceContentsSchema, + TitledMultiSelectEnumSchemaSchema, + TitledSingleSelectEnumSchemaSchema, + ToolAnnotationsSchema, + ToolChoiceSchema, + ToolExecutionSchema, + ToolListChangedNotificationSchema, + ToolResultContentSchema, + ToolSchema, + ToolUseContentSchema, + UnsubscribeRequestParamsSchema, + UnsubscribeRequestSchema, + UntitledMultiSelectEnumSchemaSchema, + UntitledSingleSelectEnumSchemaSchema +} from './schemas.js'; + +/* JSON types */ +export type JSONValue = string | number | boolean | null | JSONObject | JSONArray; +export type JSONObject = { [key: string]: JSONValue }; +export type JSONArray = JSONValue[]; + +/** + * Utility types + */ +type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; + +type Primitive = string | number | boolean | bigint | null | undefined; +type Flatten = T extends Primitive + ? T + : T extends Array + ? Array> + : T extends Set + ? Set> + : T extends Map + ? Map, Flatten> + : T extends object + ? { [K in keyof T]: Flatten } + : T; + +type Infer = Flatten>; + +/* JSON-RPC types */ +export type ProgressToken = Infer; +export type Cursor = Infer; +export type Request = Infer; +export type TaskAugmentedRequestParams = Infer; +export type RequestMeta = Infer; +export type Notification = Infer; +export type Result = Infer; +export type RequestId = Infer; +export type JSONRPCRequest = Infer; +export type JSONRPCNotification = Infer; +export type JSONRPCResponse = Infer; +export type JSONRPCErrorResponse = Infer; +export type JSONRPCResultResponse = Infer; +export type JSONRPCMessage = Infer; +export type RequestParams = Infer; +export type NotificationParams = Infer; + +/* Empty result */ +export type EmptyResult = Infer; + +/* Cancellation */ +export type CancelledNotificationParams = Infer; +export type CancelledNotification = Infer; + +/* Base Metadata */ +export type Icon = Infer; +export type Icons = Infer; +export type BaseMetadata = Infer; +export type Annotations = Infer; +export type Role = Infer; + +/* Initialization */ +export type Implementation = Infer; +export type ClientCapabilities = Infer; +export type InitializeRequestParams = Infer; +export type InitializeRequest = Infer; +export type ServerCapabilities = Infer; +export type InitializeResult = Infer; +export type InitializedNotification = Infer; + +/* Ping */ +export type PingRequest = Infer; + +/* Progress notifications */ +export type Progress = Infer; +export type ProgressNotificationParams = Infer; +export type ProgressNotification = Infer; + +/* Tasks */ +export type Task = Infer; +export type TaskStatus = Infer; +export type TaskCreationParams = Infer; +export type TaskMetadata = Infer; +export type RelatedTaskMetadata = Infer; +export type CreateTaskResult = Infer; +export type TaskStatusNotificationParams = Infer; +export type TaskStatusNotification = Infer; +export type GetTaskRequest = Infer; +export type GetTaskResult = Infer; +export type GetTaskPayloadRequest = Infer; +export type ListTasksRequest = Infer; +export type ListTasksResult = Infer; +export type CancelTaskRequest = Infer; +export type CancelTaskResult = Infer; +export type GetTaskPayloadResult = Infer; + +/* Pagination */ +export type PaginatedRequestParams = Infer; +export type PaginatedRequest = Infer; +export type PaginatedResult = Infer; + +/* Resources */ +export type ResourceContents = Infer; +export type TextResourceContents = Infer; +export type BlobResourceContents = Infer; +export type Resource = Infer; +// TODO: Overlaps with exported `ResourceTemplate` class from `server`. +export type ResourceTemplateType = Infer; +export type ListResourcesRequest = Infer; +export type ListResourcesResult = Infer; +export type ListResourceTemplatesRequest = Infer; +export type ListResourceTemplatesResult = Infer; +export type ResourceRequestParams = Infer; +export type ReadResourceRequestParams = Infer; +export type ReadResourceRequest = Infer; +export type ReadResourceResult = Infer; +export type ResourceListChangedNotification = Infer; +export type SubscribeRequestParams = Infer; +export type SubscribeRequest = Infer; +export type UnsubscribeRequestParams = Infer; +export type UnsubscribeRequest = Infer; +export type ResourceUpdatedNotificationParams = Infer; +export type ResourceUpdatedNotification = Infer; + +/* Prompts */ +export type PromptArgument = Infer; +export type Prompt = Infer; +export type ListPromptsRequest = Infer; +export type ListPromptsResult = Infer; +export type GetPromptRequestParams = Infer; +export type GetPromptRequest = Infer; +export type TextContent = Infer; +export type ImageContent = Infer; +export type AudioContent = Infer; +export type ToolUseContent = Infer; +export type ToolResultContent = Infer; +export type EmbeddedResource = Infer; +export type ResourceLink = Infer; +export type ContentBlock = Infer; +export type PromptMessage = Infer; +export type GetPromptResult = Infer; +export type PromptListChangedNotification = Infer; + +/* Tools */ +export type ToolAnnotations = Infer; +export type ToolExecution = Infer; +export type Tool = Infer; +export type ListToolsRequest = Infer; +export type ListToolsResult = Infer; +export type CallToolRequestParams = Infer; +export type CallToolResult = Infer; +export type CompatibilityCallToolResult = Infer; +export type CallToolRequest = Infer; +export type ToolListChangedNotification = Infer; + +/* Logging */ +export type LoggingLevel = Infer; +export type SetLevelRequestParams = Infer; +export type SetLevelRequest = Infer; +export type LoggingMessageNotificationParams = Infer; +export type LoggingMessageNotification = Infer; + +/* Sampling */ +export type ToolChoice = Infer; +export type ModelHint = Infer; +export type ModelPreferences = Infer; +export type SamplingContent = Infer; +export type SamplingMessageContentBlock = Infer; +export type SamplingMessage = Infer; +export type CreateMessageRequestParams = Infer; +export type CreateMessageRequest = Infer; +export type CreateMessageResult = Infer; +export type CreateMessageResultWithTools = Infer; + +/* Elicitation */ +export type BooleanSchema = Infer; +export type StringSchema = Infer; +export type NumberSchema = Infer; +export type EnumSchema = Infer; +export type UntitledSingleSelectEnumSchema = Infer; +export type TitledSingleSelectEnumSchema = Infer; +export type LegacyTitledEnumSchema = Infer; +export type UntitledMultiSelectEnumSchema = Infer; +export type TitledMultiSelectEnumSchema = Infer; +export type SingleSelectEnumSchema = Infer; +export type MultiSelectEnumSchema = Infer; +export type PrimitiveSchemaDefinition = Infer; +export type ElicitRequestParams = Infer; +export type ElicitRequestFormParams = Infer; +export type ElicitRequestURLParams = Infer; +export type ElicitRequest = Infer; +export type ElicitationCompleteNotificationParams = Infer; +export type ElicitationCompleteNotification = Infer; +export type ElicitResult = Infer; + +/* Autocomplete */ +export type ResourceTemplateReference = Infer; +export type PromptReference = Infer; +export type CompleteRequestParams = Infer; +export type CompleteRequest = Infer; +export type CompleteResult = Infer; + +/* Roots */ +export type Root = Infer; +export type ListRootsRequest = Infer; +export type ListRootsResult = Infer; +export type RootsListChangedNotification = Infer; + +/* Client messages */ +export type ClientRequest = Infer; +export type ClientNotification = Infer; +export type ClientResult = Infer; + +/* Server messages */ +export type ServerRequest = Infer; +export type ServerNotification = Infer; +export type ServerResult = Infer; + +/* Protocol type maps */ +type MethodToTypeMap = { + [T in U as T extends { method: infer M extends string } ? M : never]: T; +}; +export type RequestMethod = ClientRequest['method'] | ServerRequest['method']; +export type NotificationMethod = ClientNotification['method'] | ServerNotification['method']; +export type RequestTypeMap = MethodToTypeMap; +export type NotificationTypeMap = MethodToTypeMap; +export type ResultTypeMap = { + ping: EmptyResult; + initialize: InitializeResult; + 'completion/complete': CompleteResult; + 'logging/setLevel': EmptyResult; + 'prompts/get': GetPromptResult; + 'prompts/list': ListPromptsResult; + 'resources/list': ListResourcesResult; + 'resources/templates/list': ListResourceTemplatesResult; + 'resources/read': ReadResourceResult; + 'resources/subscribe': EmptyResult; + 'resources/unsubscribe': EmptyResult; + 'tools/call': CallToolResult | CreateTaskResult; + 'tools/list': ListToolsResult; + 'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools | CreateTaskResult; + 'elicitation/create': ElicitResult | CreateTaskResult; + 'roots/list': ListRootsResult; + 'tasks/get': GetTaskResult; + 'tasks/result': Result; + 'tasks/list': ListTasksResult; + 'tasks/cancel': CancelTaskResult; +}; + +/** + * Information about a validated access token, provided to request handlers. + */ +export interface AuthInfo { + /** + * The access token. + */ + token: string; + + /** + * The client ID associated with this token. + */ + clientId: string; + + /** + * Scopes associated with this token. + */ + scopes: string[]; + + /** + * When the token expires (in seconds since epoch). + */ + expiresAt?: number; + + /** + * The RFC 8707 resource server identifier for which this token is valid. + * If set, this MUST match the MCP server's resource identifier (minus hash fragment). + */ + resource?: URL; + + /** + * Additional data associated with the token. + * This field should be used for any additional data that needs to be attached to the auth info. + */ + extra?: Record; +} + +type JSONRPCErrorObject = { code: number; message: string; data?: unknown }; + +export interface ParseError extends JSONRPCErrorObject { + code: typeof PARSE_ERROR; +} +export interface InvalidRequestError extends JSONRPCErrorObject { + code: typeof INVALID_REQUEST; +} +export interface MethodNotFoundError extends JSONRPCErrorObject { + code: typeof METHOD_NOT_FOUND; +} +export interface InvalidParamsError extends JSONRPCErrorObject { + code: typeof INVALID_PARAMS; +} +export interface InternalError extends JSONRPCErrorObject { + code: typeof INTERNAL_ERROR; +} + +/** + * Callback type for list changed notifications. + */ +export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; + +/** + * Options for subscribing to list changed notifications. + * + * @typeParam T - The type of items in the list (`Tool`, `Prompt`, or `Resource`) + */ +export type ListChangedOptions = { + /** + * If `true`, the list will be refreshed automatically when a list changed notification is received. + * @default true + */ + autoRefresh?: boolean; + /** + * Debounce time in milliseconds. Set to `0` to disable. + * @default 300 + */ + debounceMs?: number; + /** + * Callback invoked when the list changes. + * + * If `autoRefresh` is `true`, `items` contains the updated list. + * If `autoRefresh` is `false`, `items` is `null` (caller should refresh manually). + */ + onChanged: ListChangedCallback; +}; + +/** + * Configuration for list changed notification handlers. + * + * Use this to configure handlers for tools, prompts, and resources list changes + * when creating a client. + * + * Note: Handlers are only activated if the server advertises the corresponding + * `listChanged` capability (e.g., `tools.listChanged: true`). If the server + * doesn't advertise this capability, the handler will not be set up. + */ +export type ListChangedHandlers = { + /** + * Handler for tool list changes. + */ + tools?: ListChangedOptions; + /** + * Handler for prompt list changes. + */ + prompts?: ListChangedOptions; + /** + * Handler for resource list changes. + */ + resources?: ListChangedOptions; +}; + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The original HTTP request. + */ + request?: globalThis.Request; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; + + /** + * Callback to close the SSE stream for this request, triggering client reconnection. + * Only available when using {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} with eventStore configured. + */ + closeSSEStream?: () => void; + + /** + * Callback to close the standalone GET SSE stream, triggering client reconnection. + * Only available when using {@linkcode @modelcontextprotocol/node!streamableHttp.NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} with eventStore configured. + */ + closeStandaloneSSEStream?: () => void; +} + +export type MetaObject = Record; +export type RequestMetaObject = RequestMeta; + +/** + * {@linkcode CreateMessageRequestParams} without tools - for backwards-compatible overload. + * Excludes tools/toolChoice to indicate they should not be provided. + */ +export type CreateMessageRequestParamsBase = Omit; + +/** + * {@linkcode CreateMessageRequestParams} with required tools - for tool-enabled overload. + */ +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + tools: Tool[]; +} + +export type CompleteRequestResourceTemplate = ExpandRecursively< + CompleteRequest & { params: CompleteRequestParams & { ref: ResourceTemplateReference } } +>; +export type CompleteRequestPrompt = ExpandRecursively; diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts new file mode 100644 index 0000000..4e79932 --- /dev/null +++ b/packages/core/src/util/inMemory.ts @@ -0,0 +1,73 @@ +import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js'; +import type { Transport } from '../shared/transport.js'; +import type { AuthInfo, JSONRPCMessage, RequestId } from '../types/index.js'; + +interface QueuedMessage { + message: JSONRPCMessage; + extra?: { authInfo?: AuthInfo }; +} + +/** + * In-memory transport for creating clients and servers that talk to each other within the same process. + * + * Intended for testing and development. For production in-process connections, use + * `StreamableHTTPClientTransport` against a local server URL. + */ +export class InMemoryTransport implements Transport { + private _otherTransport?: InMemoryTransport; + private _messageQueue: QueuedMessage[] = []; + private _closed = false; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + sessionId?: string; + + /** + * Creates a pair of linked in-memory transports that can communicate with each other. One should be passed to a {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and one to a {@linkcode @modelcontextprotocol/server!server/server.Server | Server}. + */ + static createLinkedPair(): [InMemoryTransport, InMemoryTransport] { + const clientTransport = new InMemoryTransport(); + const serverTransport = new InMemoryTransport(); + clientTransport._otherTransport = serverTransport; + serverTransport._otherTransport = clientTransport; + return [clientTransport, serverTransport]; + } + + async start(): Promise { + // Process any messages that were queued before start was called + while (this._messageQueue.length > 0) { + const queuedMessage = this._messageQueue.shift()!; + this.onmessage?.(queuedMessage.message, queuedMessage.extra); + } + } + + async close(): Promise { + if (this._closed) return; + this._closed = true; + + const other = this._otherTransport; + this._otherTransport = undefined; + try { + await other?.close(); + } finally { + this.onclose?.(); + } + } + + /** + * Sends a message with optional auth info. + * This is useful for testing authentication scenarios. + */ + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId; authInfo?: AuthInfo }): Promise { + if (!this._otherTransport) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + + if (this._otherTransport.onmessage) { + this._otherTransport.onmessage(message, { authInfo: options?.authInfo }); + } else { + this._otherTransport._messageQueue.push({ message, extra: { authInfo: options?.authInfo } }); + } + } +} diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts new file mode 100644 index 0000000..9676674 --- /dev/null +++ b/packages/core/src/util/schema.ts @@ -0,0 +1,32 @@ +/** + * Internal Zod schema utilities for protocol handling. + * These are used internally by the SDK for protocol message validation. + */ + +import * as z from 'zod/v4'; + +/** + * Base type for any Zod schema. + */ +export type AnySchema = z.core.$ZodType; + +/** + * A Zod schema for objects specifically. + */ +export type AnyObjectSchema = z.core.$ZodObject; + +/** + * Extracts the output type from a Zod schema. + */ +export type SchemaOutput = z.output; + +/** + * Parses data against a Zod schema (synchronous). + * Returns a discriminated union with success/error. + */ +export function parseSchema( + schema: T, + data: unknown +): { success: true; data: z.output } | { success: false; error: z.core.$ZodError } { + return z.safeParse(schema, data); +} diff --git a/packages/core/src/util/standardSchema.ts b/packages/core/src/util/standardSchema.ts new file mode 100644 index 0000000..b938885 --- /dev/null +++ b/packages/core/src/util/standardSchema.ts @@ -0,0 +1,251 @@ +/** + * Standard Schema utilities for user-provided schemas. + * Supports Zod v4, Valibot, ArkType, and other Standard Schema implementations. + * @see https://standardschema.dev + */ + +/* eslint-disable @typescript-eslint/no-namespace */ + +import * as z from 'zod/v4'; + +// Standard Schema interfaces — vendored from https://standardschema.dev (spec v1, Jan 2025) + +export interface StandardTypedV1 { + readonly '~standard': StandardTypedV1.Props; +} + +export namespace StandardTypedV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly types?: Types | undefined; + } + + export interface Types { + readonly input: Input; + readonly output: Output; + } + + export type InferInput = NonNullable['input']; + export type InferOutput = NonNullable['output']; +} + +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props; +} + +export namespace StandardSchemaV1 { + export interface Props extends StandardTypedV1.Props { + readonly validate: (value: unknown, options?: Options | undefined) => Result | Promise>; + } + + export interface Options { + readonly libraryOptions?: Record | undefined; + } + + export type Result = SuccessResult | FailureResult; + + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + + export interface FailureResult { + readonly issues: ReadonlyArray; + } + + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + + export interface PathSegment { + readonly key: PropertyKey; + } + + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +export interface StandardJSONSchemaV1 { + readonly '~standard': StandardJSONSchemaV1.Props; +} + +export namespace StandardJSONSchemaV1 { + export interface Props extends StandardTypedV1.Props { + readonly jsonSchema: Converter; + } + + export interface Converter { + readonly input: (options: Options) => Record; + readonly output: (options: Options) => Record; + } + + export type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string); + + export interface Options { + readonly target: Target; + readonly libraryOptions?: Record | undefined; + } + + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +/** + * Combined interface for schemas with both validation and JSON Schema conversion — + * the intersection of {@linkcode StandardSchemaV1} and {@linkcode StandardJSONSchemaV1}. + * + * This is the type accepted by `registerTool` / `registerPrompt`. The SDK needs + * `~standard.jsonSchema` to advertise the tool's argument shape in `tools/list`, and + * `~standard.validate` to check incoming arguments when a `tools/call` arrives. + * + * Zod v4, ArkType, and Valibot (via `@valibot/to-json-schema`'s `toStandardJsonSchema`) + * all implement both interfaces. + * + * @see https://standardschema.dev/ for the Standard Schema specification + */ +export interface StandardSchemaWithJSON { + readonly '~standard': StandardSchemaV1.Props & StandardJSONSchemaV1.Props; +} + +export namespace StandardSchemaWithJSON { + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +/** + * Narrowing of {@linkcode StandardSchemaV1} whose `validate` is guaranteed synchronous. + * + * The Zod schemas backing `specTypeSchemas` contain no async refinements or transforms, + * so every entry satisfies this interface. Consumers can call `validate()` and access + * `.issues` / `.value` on the result without `await`. + * + * `StandardSchemaV1Sync` is assignable to `StandardSchemaV1` — it is a strict subtype. + */ +export interface StandardSchemaV1Sync extends StandardSchemaV1 { + readonly '~standard': StandardSchemaV1Sync.Props; +} + +export namespace StandardSchemaV1Sync { + export interface Props extends StandardSchemaV1.Props { + readonly validate: (value: unknown, options?: StandardSchemaV1.Options | undefined) => StandardSchemaV1.Result; + } + + export type InferInput = StandardTypedV1.InferInput; + export type InferOutput = StandardTypedV1.InferOutput; +} + +// Type guards + +export function isStandardJSONSchema(schema: unknown): schema is StandardJSONSchemaV1 { + if (schema == null) return false; + const schemaType = typeof schema; + if (schemaType !== 'object' && schemaType !== 'function') return false; + if (!('~standard' in (schema as object))) return false; + const std = (schema as StandardJSONSchemaV1)['~standard']; + return typeof std?.jsonSchema?.input === 'function' && typeof std?.jsonSchema?.output === 'function'; +} + +export function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { + if (schema == null) return false; + const schemaType = typeof schema; + if (schemaType !== 'object' && schemaType !== 'function') return false; + if (!('~standard' in (schema as object))) return false; + const std = (schema as StandardSchemaV1)['~standard']; + return typeof std?.validate === 'function'; +} + +export function isStandardSchemaWithJSON(schema: unknown): schema is StandardSchemaWithJSON { + return isStandardJSONSchema(schema) && isStandardSchema(schema); +} + +// JSON Schema conversion + +let warnedZodFallback = false; + +/** + * Converts a StandardSchema to JSON Schema for use as an MCP tool/prompt schema. + * + * MCP requires `type: "object"` at the root of tool inputSchema/outputSchema and + * prompt argument schemas. Zod's discriminated unions emit `{oneOf: [...]}` without + * a top-level `type`, so this function defaults `type` to `"object"` when absent. + * + * Throws if the schema has an explicit non-object `type` (e.g. `z.string()`), + * since that cannot satisfy the MCP spec. + */ +export function standardSchemaToJsonSchema(schema: StandardJSONSchemaV1, io: 'input' | 'output' = 'input'): Record { + const std = schema['~standard']; + let result: Record; + if (std.jsonSchema) { + result = std.jsonSchema[io]({ target: 'draft-2020-12' }); + } else if (std.vendor === 'zod') { + // zod 4.0–4.1 implements StandardSchemaV1 but not StandardJSONSchemaV1 (`~standard.jsonSchema`). + // The SDK already bundles zod 4, so fall back to its converter rather than crashing on tools/list. + // zod 3 schemas (which also report vendor 'zod') have `_def` but not `_zod`; the SDK-bundled + // zod 4 `z.toJSONSchema()` cannot introspect them, so throw a clear error instead of crashing. + if (!('_zod' in (schema as object))) { + throw new Error( + 'Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. ' + + 'Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema().' + ); + } + if (!warnedZodFallback) { + warnedZodFallback = true; + console.warn( + '[mcp-sdk] Your zod version does not implement `~standard.jsonSchema` (added in zod 4.2.0). ' + + 'Falling back to z.toJSONSchema(). Upgrade to zod >=4.2.0 to silence this warning.' + ); + } + result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record; + } else { + throw new Error( + `Schema library "${std.vendor}" does not implement StandardJSONSchemaV1 (\`~standard.jsonSchema\`). ` + + `Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().` + ); + } + if (result.type !== undefined && result.type !== 'object') { + throw new Error( + `MCP tool and prompt schemas must describe objects (got type: ${JSON.stringify(result.type)}). ` + + `Wrap your schema in z.object({...}) or equivalent.` + ); + } + return { type: 'object', ...result }; +} + +// Validation + +export type StandardSchemaValidationResult = { success: true; data: T } | { success: false; error: string }; + +function formatIssue(issue: StandardSchemaV1.Issue): string { + if (!issue.path?.length) return issue.message; + const path = issue.path.map(p => String(typeof p === 'object' ? p.key : p)).join('.'); + return `${path}: ${issue.message}`; +} + +export async function validateStandardSchema( + schema: T, + data: unknown +): Promise>> { + const result = await schema['~standard'].validate(data); + if (result.issues && result.issues.length > 0) { + return { success: false, error: result.issues.map(i => formatIssue(i)).join(', ') }; + } + return { success: true, data: (result as StandardSchemaV1.SuccessResult).value as StandardSchemaV1.InferOutput }; +} + +// Prompt argument extraction + +export function promptArgumentsFromStandardSchema( + schema: StandardJSONSchemaV1 +): Array<{ name: string; description?: string; required: boolean }> { + const jsonSchema = standardSchemaToJsonSchema(schema, 'input'); + const properties = (jsonSchema.properties as Record) || {}; + const required = (jsonSchema.required as string[]) || []; + + return Object.entries(properties).map(([name, prop]) => ({ + name, + description: prop?.description, + required: required.includes(name) + })); +} diff --git a/packages/core/src/util/zodCompat.ts b/packages/core/src/util/zodCompat.ts new file mode 100644 index 0000000..3bb2088 --- /dev/null +++ b/packages/core/src/util/zodCompat.ts @@ -0,0 +1,80 @@ +/** + * Zod-specific helpers for the v1-compat raw-shape shorthand on + * `registerTool`/`registerPrompt`. Kept separate from `standardSchema.ts` so + * that file stays library-agnostic per the Standard Schema spec. + */ + +import * as z from 'zod/v4'; + +import type { StandardSchemaWithJSON } from './standardSchema.js'; +import { isStandardSchema } from './standardSchema.js'; + +function isZodV4Schema(v: unknown): v is z.ZodType { + // `_zod` is the v4 internal namespace property. Zod v3 schemas have `_def` + // and (since 3.24) `~standard.vendor === 'zod'`, but never `_zod`. We require + // v4 because the wrap path below uses v4's `z.object()`, which cannot consume + // v3 field schemas. + return typeof v === 'object' && v !== null && '_zod' in v; +} + +function looksLikeZodV3(v: unknown): boolean { + // v3 schemas have `_def.typeName` (e.g. 'ZodString') and no `_zod`. + return ( + typeof v === 'object' && + v !== null && + !('_zod' in v) && + '_def' in v && + typeof (v as { _def?: { typeName?: unknown } })._def?.typeName === 'string' + ); +} + +/** + * Detects a "raw shape" — a plain object whose values are Zod field schemas, + * e.g. `{ name: z.string() }`. Powers the auto-wrap in + * {@linkcode normalizeRawShapeSchema}, which wraps with `z.object()`, so only + * Zod values are supported. + * + * @internal + */ +export function isZodRawShape(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) return false; + if (isStandardSchema(obj)) return false; + // Require a plain object literal: rejects arrays, Date, Map, RegExp, class instances, etc. + // Object.create(null) is also accepted. + const proto = Object.getPrototypeOf(obj); + if (proto !== Object.prototype && proto !== null) return false; + // [].every() is true, so an empty plain object is a valid raw shape (matches v1). + return Object.values(obj).every(v => isZodV4Schema(v)); +} + +/** + * Accepts either a {@linkcode StandardSchemaWithJSON} or a raw Zod shape + * `{ field: z.string() }` and returns a {@linkcode StandardSchemaWithJSON}. + * Raw shapes are wrapped with `z.object()` so the rest of the pipeline sees a + * uniform schema type; already-wrapped schemas pass through unchanged. + * + * @internal + */ +export function normalizeRawShapeSchema( + schema: StandardSchemaWithJSON | Record | undefined +): StandardSchemaWithJSON | undefined { + if (schema === undefined) return undefined; + if (isZodRawShape(schema)) { + return z.object(schema) as StandardSchemaWithJSON; + } + if (typeof schema === 'object' && schema !== null && !isStandardSchema(schema) && Object.values(schema).some(v => looksLikeZodV3(v))) { + throw new TypeError( + 'Raw-shape inputSchema/outputSchema/argsSchema fields must be Zod v4 schemas. Got a Zod v3 field schema. Import from `zod/v4` (or upgrade your zod import), or wrap with `z.object({...})` yourself.' + ); + } + if (!isStandardSchema(schema)) { + throw new TypeError( + 'inputSchema/outputSchema/argsSchema must be a Standard Schema (e.g. z.object({...})) or a raw Zod shape ({ field: z.string() }).' + ); + } + // Any StandardSchema passes through; standardSchemaToJsonSchema owns the per-vendor + // handling for schemas without `~standard.jsonSchema` (zod 4.0-4.1 fallback, zod 3 + // and non-zod errors). Gating on `~standard.jsonSchema` here would unreachably + // front-run that fallback. + return schema; +} diff --git a/packages/core/src/validators/ajvProvider.examples.ts b/packages/core/src/validators/ajvProvider.examples.ts new file mode 100644 index 0000000..eea45bf --- /dev/null +++ b/packages/core/src/validators/ajvProvider.examples.ts @@ -0,0 +1,48 @@ +/** + * Type-checked examples for `ajvProvider.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { Ajv } from 'ajv'; +import _addFormats from 'ajv-formats'; + +import { AjvJsonSchemaValidator } from './ajvProvider.js'; + +const addFormats = _addFormats as unknown as typeof _addFormats.default; + +/** + * Example: Default AJV instance. + */ +function AjvJsonSchemaValidator_default() { + //#region AjvJsonSchemaValidator_default + const validator = new AjvJsonSchemaValidator(); + //#endregion AjvJsonSchemaValidator_default + return validator; +} + +/** + * Example: Custom AJV instance. + */ +function AjvJsonSchemaValidator_customInstance() { + //#region AjvJsonSchemaValidator_customInstance + const ajv = new Ajv({ strict: true, allErrors: true }); + const validator = new AjvJsonSchemaValidator(ajv); + //#endregion AjvJsonSchemaValidator_customInstance + return validator; +} + +/** + * Example: Constructor with advanced AJV configuration including formats. + */ +function AjvJsonSchemaValidator_constructor_withFormats() { + //#region AjvJsonSchemaValidator_constructor_withFormats + const ajv = new Ajv({ validateFormats: true }); + addFormats(ajv); + const validator = new AjvJsonSchemaValidator(ajv); + //#endregion AjvJsonSchemaValidator_constructor_withFormats + return validator; +} diff --git a/packages/core/src/validators/ajvProvider.ts b/packages/core/src/validators/ajvProvider.ts new file mode 100644 index 0000000..820a3d6 --- /dev/null +++ b/packages/core/src/validators/ajvProvider.ts @@ -0,0 +1,94 @@ +/** + * AJV-based JSON Schema validator provider + */ + +import { Ajv } from 'ajv'; +import _addFormats from 'ajv-formats'; + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; + +function createDefaultAjvInstance(): Ajv { + const ajv = new Ajv({ + strict: false, + validateFormats: true, + validateSchema: false, + allErrors: true + }); + + const addFormats = _addFormats as unknown as typeof _addFormats.default; + addFormats(ajv); + + return ajv; +} + +/** + * @example Use with default AJV instance (recommended) + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" + * const validator = new AjvJsonSchemaValidator(); + * ``` + * + * @example Use with custom AJV instance + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_customInstance" + * const ajv = new Ajv({ strict: true, allErrors: true }); + * const validator = new AjvJsonSchemaValidator(ajv); + * ``` + * + * @see `CfWorkerJsonSchemaValidator` for an edge-runtime-compatible alternative (import from `@modelcontextprotocol/server/validators/cf-worker` or `@modelcontextprotocol/client/validators/cf-worker`) + */ +export class AjvJsonSchemaValidator implements jsonSchemaValidator { + private _ajv: Ajv; + + /** + * Create an AJV validator + * + * @param ajv - Optional pre-configured AJV instance. If not provided, a default instance will be created. + * + * @example Use default configuration (recommended for most cases) + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_default" + * const validator = new AjvJsonSchemaValidator(); + * ``` + * + * @example Provide custom AJV instance for advanced configuration + * ```ts source="./ajvProvider.examples.ts#AjvJsonSchemaValidator_constructor_withFormats" + * const ajv = new Ajv({ validateFormats: true }); + * addFormats(ajv); + * const validator = new AjvJsonSchemaValidator(ajv); + * ``` + */ + constructor(ajv?: Ajv) { + this._ajv = ajv ?? createDefaultAjvInstance(); + } + + /** + * Create a validator for the given JSON Schema + * + * The validator is compiled once and can be reused multiple times. + * If the schema has an `$id`, it will be cached by AJV automatically. + * + * @param schema - Standard JSON Schema object + * @returns A validator function that validates input data + */ + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Check if schema has $id and is already compiled/cached + const ajvValidator = + '$id' in schema && typeof schema.$id === 'string' + ? (this._ajv.getSchema(schema.$id) ?? this._ajv.compile(schema)) + : this._ajv.compile(schema); + + return (input: unknown): JsonSchemaValidatorResult => { + const valid = ajvValidator(input); + + return valid + ? { + valid: true, + data: input as T, + errorMessage: undefined + } + : { + valid: false, + data: undefined, + errorMessage: this._ajv.errorsText(ajvValidator.errors) + }; + }; + } +} diff --git a/packages/core/src/validators/cfWorkerProvider.examples.ts b/packages/core/src/validators/cfWorkerProvider.examples.ts new file mode 100644 index 0000000..a347f9b --- /dev/null +++ b/packages/core/src/validators/cfWorkerProvider.examples.ts @@ -0,0 +1,33 @@ +/** + * Type-checked examples for `cfWorkerProvider.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { CfWorkerJsonSchemaValidator } from './cfWorkerProvider.js'; + +/** + * Example: Default configuration. + */ +function CfWorkerJsonSchemaValidator_default() { + //#region CfWorkerJsonSchemaValidator_default + const validator = new CfWorkerJsonSchemaValidator(); + //#endregion CfWorkerJsonSchemaValidator_default + return validator; +} + +/** + * Example: Custom configuration with all errors reported. + */ +function CfWorkerJsonSchemaValidator_customConfig() { + //#region CfWorkerJsonSchemaValidator_customConfig + const validator = new CfWorkerJsonSchemaValidator({ + draft: '2020-12', + shortcircuit: false // Report all errors + }); + //#endregion CfWorkerJsonSchemaValidator_customConfig + return validator; +} diff --git a/packages/core/src/validators/cfWorkerProvider.ts b/packages/core/src/validators/cfWorkerProvider.ts new file mode 100644 index 0000000..f2cce37 --- /dev/null +++ b/packages/core/src/validators/cfWorkerProvider.ts @@ -0,0 +1,79 @@ +/** + * Cloudflare Worker-compatible JSON Schema validator provider + * + * This provider uses @cfworker/json-schema for validation without code generation, + * making it compatible with edge runtimes like Cloudflare Workers that restrict + * eval and new Function. + * + * @see {@linkcode AjvJsonSchemaValidator} for the Node.js alternative + */ + +import { Validator } from '@cfworker/json-schema'; + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; + +/** + * JSON Schema draft version supported by @cfworker/json-schema + */ +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +/** + * + * @example Use with default configuration (2020-12, shortcircuit) + * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_default" + * const validator = new CfWorkerJsonSchemaValidator(); + * ``` + * + * @example Use with custom configuration + * ```ts source="./cfWorkerProvider.examples.ts#CfWorkerJsonSchemaValidator_customConfig" + * const validator = new CfWorkerJsonSchemaValidator({ + * draft: '2020-12', + * shortcircuit: false // Report all errors + * }); + * ``` + */ +export class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { + private shortcircuit: boolean; + private draft: CfWorkerSchemaDraft; + + /** + * Create a validator + * + * @param options - Configuration options + * @param options.shortcircuit - If `true`, stop validation after first error (default: `true`) + * @param options.draft - JSON Schema draft version to use (default: `'2020-12'`) + */ + constructor(options?: { shortcircuit?: boolean; draft?: CfWorkerSchemaDraft }) { + this.shortcircuit = options?.shortcircuit ?? true; + this.draft = options?.draft ?? '2020-12'; + } + + /** + * Create a validator for the given JSON Schema + * + * Unlike AJV, this validator is not cached internally + * + * @param schema - Standard JSON Schema object + * @returns A validator function that validates input data + */ + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Cast to the cfworker Schema type - our JsonSchemaType is structurally compatible + const validator = new Validator(schema as ConstructorParameters[0], this.draft, this.shortcircuit); + + return (input: unknown): JsonSchemaValidatorResult => { + const result = validator.validate(input); + + return result.valid + ? { + valid: true, + data: input as T, + errorMessage: undefined + } + : { + valid: false, + data: undefined, + errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ') + }; + }; + } +} diff --git a/packages/core/src/validators/fromJsonSchema.examples.ts b/packages/core/src/validators/fromJsonSchema.examples.ts new file mode 100644 index 0000000..22ff4a9 --- /dev/null +++ b/packages/core/src/validators/fromJsonSchema.examples.ts @@ -0,0 +1,24 @@ +/** + * Type-checked examples for `fromJsonSchema.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * + * @module + */ + +import { AjvJsonSchemaValidator } from './ajvProvider.js'; +import { fromJsonSchema } from './fromJsonSchema.js'; + +/** + * Example: wrap a raw JSON Schema object for use with registerTool. + */ +function fromJsonSchema_basicUsage() { + //#region fromJsonSchema_basicUsage + const inputSchema = fromJsonSchema<{ name: string }>( + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + new AjvJsonSchemaValidator() + ); + // Use with server.registerTool('greet', { inputSchema }, handler) + //#endregion fromJsonSchema_basicUsage + return inputSchema; +} diff --git a/packages/core/src/validators/fromJsonSchema.ts b/packages/core/src/validators/fromJsonSchema.ts new file mode 100644 index 0000000..73db24e --- /dev/null +++ b/packages/core/src/validators/fromJsonSchema.ts @@ -0,0 +1,43 @@ +import type { StandardSchemaV1, StandardSchemaWithJSON } from '../util/standardSchema.js'; +import type { JsonSchemaType, jsonSchemaValidator } from './types.js'; + +/** + * Wrap a raw JSON Schema object as a {@linkcode StandardSchemaWithJSON} so it can be + * passed to `registerTool` / `registerPrompt`. Use this when you already have JSON + * Schema (e.g. from TypeBox, or hand-written) and want to register it without going + * through a Standard Schema library. + * + * The callback arguments will be typed `unknown` (raw JSON Schema has no TypeScript + * types attached). Cast at the call site, or use the generic `fromJsonSchema(...)`. + * + * @param schema - A JSON Schema object describing the expected shape + * @param validator - A validator provider. When importing `fromJsonSchema` from + * `@modelcontextprotocol/server` or `@modelcontextprotocol/client`, a runtime-appropriate + * default is provided automatically (AJV on Node.js, CfWorker on edge runtimes). + * + * @example + * ```ts source="./fromJsonSchema.examples.ts#fromJsonSchema_basicUsage" + * const inputSchema = fromJsonSchema<{ name: string }>( + * { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + * new AjvJsonSchemaValidator() + * ); + * // Use with server.registerTool('greet', { inputSchema }, handler) + * ``` + */ +export function fromJsonSchema(schema: JsonSchemaType, validator: jsonSchemaValidator): StandardSchemaWithJSON { + const check = validator.getValidator(schema); + return { + '~standard': { + version: 1, + vendor: 'mcp', + jsonSchema: { + input: () => schema as Record, + output: () => schema as Record + }, + validate: (data: unknown): StandardSchemaV1.Result => { + const result = check(data); + return result.valid ? { value: result.data } : { issues: [{ message: result.errorMessage }] }; + } + } + }; +} diff --git a/packages/core/src/validators/types.examples.ts b/packages/core/src/validators/types.examples.ts new file mode 100644 index 0000000..b6cd760 --- /dev/null +++ b/packages/core/src/validators/types.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `types.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from './types.js'; + +// Stub for hypothetical schema validation function +declare function isValid(schema: JsonSchemaType, input: unknown): boolean; + +/** + * Example: Implementing the jsonSchemaValidator interface. + */ +function jsonSchemaValidator_implementation() { + //#region jsonSchemaValidator_implementation + class MyValidatorProvider implements jsonSchemaValidator { + getValidator(schema: JsonSchemaType): JsonSchemaValidator { + // Compile/cache validator from schema + return (input: unknown) => + isValid(schema, input) + ? { valid: true, data: input as T, errorMessage: undefined } + : { valid: false, data: undefined, errorMessage: 'Error details' }; + } + } + //#endregion jsonSchemaValidator_implementation + return MyValidatorProvider; +} diff --git a/packages/core/src/validators/types.ts b/packages/core/src/validators/types.ts new file mode 100644 index 0000000..e2202b4 --- /dev/null +++ b/packages/core/src/validators/types.ts @@ -0,0 +1,59 @@ +// Using the main export which points to draft-2020-12 by default +import type { JSONSchema } from 'json-schema-typed'; + +/** + * JSON Schema type definition (JSON Schema Draft 2020-12) + * + * This uses the object form of JSON Schema (excluding boolean schemas). + * While `true` and `false` are valid JSON Schemas, this SDK uses the + * object form for practical type safety. + * + * Re-exported from json-schema-typed for convenience. + * @see https://json-schema.org/draft/2020-12/json-schema-core.html + */ +export type JsonSchemaType = JSONSchema.Interface; + +/** + * Result of a JSON Schema validation operation + */ +export type JsonSchemaValidatorResult = + | { valid: true; data: T; errorMessage: undefined } + | { valid: false; data: undefined; errorMessage: string }; + +/** + * A validator function that validates data against a JSON Schema + */ +export type JsonSchemaValidator = (input: unknown) => JsonSchemaValidatorResult; + +/** + * Provider interface for creating validators from JSON Schemas + * + * This is the main extension point for custom validator implementations. + * Implementations should: + * - Support JSON Schema Draft 2020-12 (or be compatible with it) + * - Return validator functions that can be called multiple times + * - Handle schema compilation/caching internally + * - Provide clear error messages on validation failure + * + * @example + * ```ts source="./types.examples.ts#jsonSchemaValidator_implementation" + * class MyValidatorProvider implements jsonSchemaValidator { + * getValidator(schema: JsonSchemaType): JsonSchemaValidator { + * // Compile/cache validator from schema + * return (input: unknown) => + * isValid(schema, input) + * ? { valid: true, data: input as T, errorMessage: undefined } + * : { valid: false, data: undefined, errorMessage: 'Error details' }; + * } + * } + * ``` + */ +export interface jsonSchemaValidator { + /** + * Create a validator for the given JSON Schema + * + * @param schema - Standard JSON Schema object + * @returns A validator function that can be called multiple times + */ + getValidator(schema: JsonSchemaType): JsonSchemaValidator; +} diff --git a/packages/core/test/experimental/inMemory.test.ts b/packages/core/test/experimental/inMemory.test.ts new file mode 100644 index 0000000..7639cad --- /dev/null +++ b/packages/core/test/experimental/inMemory.test.ts @@ -0,0 +1,1035 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { QueuedMessage } from '../../src/experimental/tasks/interfaces.js'; +import { InMemoryTaskMessageQueue, InMemoryTaskStore } from '../../src/experimental/tasks/stores/inMemory.js'; +import type { Request, TaskCreationParams } from '../../src/types/index.js'; + +describe('InMemoryTaskStore', () => { + let store: InMemoryTaskStore; + + beforeEach(() => { + store = new InMemoryTaskStore(); + }); + + afterEach(() => { + store.cleanup(); + }); + + describe('createTask', () => { + it('should create a new task with working status', async () => { + const taskParams: TaskCreationParams = { + ttl: 60_000 + }; + const request: Request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const task = await store.createTask(taskParams, 123, request); + + expect(task).toBeDefined(); + expect(task.taskId).toBeDefined(); + expect(typeof task.taskId).toBe('string'); + expect(task.taskId.length).toBeGreaterThan(0); + expect(task.status).toBe('working'); + expect(task.ttl).toBe(60_000); + expect(task.pollInterval).toBeDefined(); + expect(task.createdAt).toBeDefined(); + expect(new Date(task.createdAt).getTime()).toBeGreaterThan(0); + }); + + it('should create task without ttl', async () => { + const taskParams: TaskCreationParams = {}; + const request: Request = { + method: 'tools/call', + params: {} + }; + + const task = await store.createTask(taskParams, 456, request); + + expect(task).toBeDefined(); + expect(task.ttl).toBeNull(); + }); + + it('should generate unique taskIds', async () => { + const taskParams: TaskCreationParams = {}; + const request: Request = { + method: 'tools/call', + params: {} + }; + + const task1 = await store.createTask(taskParams, 789, request); + const task2 = await store.createTask(taskParams, 790, request); + + expect(task1.taskId).not.toBe(task2.taskId); + }); + }); + + describe('getTask', () => { + it('should return null for non-existent task', async () => { + const task = await store.getTask('non-existent'); + expect(task).toBeNull(); + }); + + it('should return task state', async () => { + const taskParams: TaskCreationParams = {}; + const request: Request = { + method: 'tools/call', + params: {} + }; + + const createdTask = await store.createTask(taskParams, 111, request); + await store.updateTaskStatus(createdTask.taskId, 'working'); + + const task = await store.getTask(createdTask.taskId); + expect(task).toBeDefined(); + expect(task?.status).toBe('working'); + }); + }); + + describe('updateTaskStatus', () => { + let taskId: string; + + beforeEach(async () => { + const taskParams: TaskCreationParams = {}; + const createdTask = await store.createTask(taskParams, 222, { + method: 'tools/call', + params: {} + }); + taskId = createdTask.taskId; + }); + + it('should keep task status as working', async () => { + const task = await store.getTask(taskId); + expect(task?.status).toBe('working'); + }); + + it('should update task status to input_required', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('input_required'); + }); + + it('should update task status to completed', async () => { + await store.updateTaskStatus(taskId, 'completed'); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('completed'); + }); + + it('should update task status to failed with error', async () => { + await store.updateTaskStatus(taskId, 'failed', 'Something went wrong'); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('failed'); + expect(task?.statusMessage).toBe('Something went wrong'); + }); + + it('should update task status to cancelled', async () => { + await store.updateTaskStatus(taskId, 'cancelled'); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('cancelled'); + }); + + it('should throw if task not found', async () => { + await expect(store.updateTaskStatus('non-existent', 'working')).rejects.toThrow('Task with ID non-existent not found'); + }); + + describe('status lifecycle validation', () => { + it('should allow transition from working to input_required', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('input_required'); + }); + + it('should allow transition from working to completed', async () => { + await store.updateTaskStatus(taskId, 'completed'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('completed'); + }); + + it('should allow transition from working to failed', async () => { + await store.updateTaskStatus(taskId, 'failed'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('failed'); + }); + + it('should allow transition from working to cancelled', async () => { + await store.updateTaskStatus(taskId, 'cancelled'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('cancelled'); + }); + + it('should allow transition from input_required to working', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + await store.updateTaskStatus(taskId, 'working'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('working'); + }); + + it('should allow transition from input_required to completed', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + await store.updateTaskStatus(taskId, 'completed'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('completed'); + }); + + it('should allow transition from input_required to failed', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + await store.updateTaskStatus(taskId, 'failed'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('failed'); + }); + + it('should allow transition from input_required to cancelled', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + await store.updateTaskStatus(taskId, 'cancelled'); + const task = await store.getTask(taskId); + expect(task?.status).toBe('cancelled'); + }); + + it('should reject transition from completed to any other status', async () => { + await store.updateTaskStatus(taskId, 'completed'); + await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task'); + }); + + it('should reject transition from failed to any other status', async () => { + await store.updateTaskStatus(taskId, 'failed'); + await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow('Cannot update task'); + }); + + it('should reject transition from cancelled to any other status', async () => { + await store.updateTaskStatus(taskId, 'cancelled'); + await expect(store.updateTaskStatus(taskId, 'working')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'input_required')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'completed')).rejects.toThrow('Cannot update task'); + await expect(store.updateTaskStatus(taskId, 'failed')).rejects.toThrow('Cannot update task'); + }); + }); + }); + + describe('storeTaskResult', () => { + let taskId: string; + + beforeEach(async () => { + const taskParams: TaskCreationParams = { + ttl: 60_000 + }; + const createdTask = await store.createTask(taskParams, 333, { + method: 'tools/call', + params: {} + }); + taskId = createdTask.taskId; + }); + + it('should store task result and set status to completed', async () => { + const result = { + content: [{ type: 'text' as const, text: 'Success!' }] + }; + + await store.storeTaskResult(taskId, 'completed', result); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('completed'); + + const storedResult = await store.getTaskResult(taskId); + expect(storedResult).toStrictEqual(result); + }); + + it('should throw if task not found', async () => { + await expect(store.storeTaskResult('non-existent', 'completed', {})).rejects.toThrow('Task with ID non-existent not found'); + }); + + it('should reject storing result for task already in completed status', async () => { + // First complete the task + const firstResult = { + content: [{ type: 'text' as const, text: 'First result' }] + }; + await store.storeTaskResult(taskId, 'completed', firstResult); + + // Try to store result again (should fail) + const secondResult = { + content: [{ type: 'text' as const, text: 'Second result' }] + }; + + await expect(store.storeTaskResult(taskId, 'completed', secondResult)).rejects.toThrow('Cannot store result for task'); + }); + + it('should store result with failed status', async () => { + const result = { + content: [{ type: 'text' as const, text: 'Error details' }], + isError: true + }; + + await store.storeTaskResult(taskId, 'failed', result); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('failed'); + + const storedResult = await store.getTaskResult(taskId); + expect(storedResult).toStrictEqual(result); + }); + + it('should reject storing result for task already in failed status', async () => { + // First fail the task + const firstResult = { + content: [{ type: 'text' as const, text: 'First error' }], + isError: true + }; + await store.storeTaskResult(taskId, 'failed', firstResult); + + // Try to store result again (should fail) + const secondResult = { + content: [{ type: 'text' as const, text: 'Second error' }], + isError: true + }; + + await expect(store.storeTaskResult(taskId, 'failed', secondResult)).rejects.toThrow('Cannot store result for task'); + }); + + it('should reject storing result for cancelled task', async () => { + // Mark task as cancelled + await store.updateTaskStatus(taskId, 'cancelled'); + + // Try to store result (should fail) + const result = { + content: [{ type: 'text' as const, text: 'Cancellation result' }] + }; + + await expect(store.storeTaskResult(taskId, 'completed', result)).rejects.toThrow('Cannot store result for task'); + }); + + it('should allow storing result from input_required status', async () => { + await store.updateTaskStatus(taskId, 'input_required'); + + const result = { + content: [{ type: 'text' as const, text: 'Success!' }] + }; + + await store.storeTaskResult(taskId, 'completed', result); + + const task = await store.getTask(taskId); + expect(task?.status).toBe('completed'); + }); + }); + + describe('getTaskResult', () => { + it('should throw if task not found', async () => { + await expect(store.getTaskResult('non-existent')).rejects.toThrow('Task with ID non-existent not found'); + }); + + it('should throw if task has no result stored', async () => { + const taskParams: TaskCreationParams = {}; + const createdTask = await store.createTask(taskParams, 444, { + method: 'tools/call', + params: {} + }); + + await expect(store.getTaskResult(createdTask.taskId)).rejects.toThrow(`Task ${createdTask.taskId} has no result stored`); + }); + + it('should return stored result', async () => { + const taskParams: TaskCreationParams = {}; + const createdTask = await store.createTask(taskParams, 555, { + method: 'tools/call', + params: {} + }); + + const result = { + content: [{ type: 'text' as const, text: 'Result data' }] + }; + await store.storeTaskResult(createdTask.taskId, 'completed', result); + + const retrieved = await store.getTaskResult(createdTask.taskId); + expect(retrieved).toStrictEqual(result); + }); + }); + + describe('ttl cleanup', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should cleanup task after ttl duration', async () => { + const taskParams: TaskCreationParams = { + ttl: 1000 + }; + const createdTask = await store.createTask(taskParams, 666, { + method: 'tools/call', + params: {} + }); + + // Task should exist initially + let task = await store.getTask(createdTask.taskId); + expect(task).toBeDefined(); + + // Fast-forward past ttl + vi.advanceTimersByTime(1001); + + // Task should be cleaned up + task = await store.getTask(createdTask.taskId); + expect(task).toBeNull(); + }); + + it('should reset cleanup timer when result is stored', async () => { + const taskParams: TaskCreationParams = { + ttl: 1000 + }; + const createdTask = await store.createTask(taskParams, 777, { + method: 'tools/call', + params: {} + }); + + // Fast-forward 500ms + vi.advanceTimersByTime(500); + + // Store result (should reset timer) + await store.storeTaskResult(createdTask.taskId, 'completed', { + content: [{ type: 'text' as const, text: 'Done' }] + }); + + // Fast-forward another 500ms (total 1000ms since creation, but timer was reset) + vi.advanceTimersByTime(500); + + // Task should still exist + const task = await store.getTask(createdTask.taskId); + expect(task).toBeDefined(); + + // Fast-forward remaining time + vi.advanceTimersByTime(501); + + // Now task should be cleaned up + const cleanedTask = await store.getTask(createdTask.taskId); + expect(cleanedTask).toBeNull(); + }); + + it('should not cleanup tasks without ttl', async () => { + const taskParams: TaskCreationParams = {}; + const createdTask = await store.createTask(taskParams, 888, { + method: 'tools/call', + params: {} + }); + + // Fast-forward a long time + vi.advanceTimersByTime(100_000); + + // Task should still exist + const task = await store.getTask(createdTask.taskId); + expect(task).toBeDefined(); + }); + + it('should start cleanup timer when task reaches terminal state', async () => { + const taskParams: TaskCreationParams = { + ttl: 1000 + }; + const createdTask = await store.createTask(taskParams, 999, { + method: 'tools/call', + params: {} + }); + + // Task in non-terminal state, fast-forward + vi.advanceTimersByTime(1001); + + // Task should be cleaned up + let task = await store.getTask(createdTask.taskId); + expect(task).toBeNull(); + + // Create another task + const taskParams2: TaskCreationParams = { + ttl: 2000 + }; + const createdTask2 = await store.createTask(taskParams2, 1000, { + method: 'tools/call', + params: {} + }); + + // Update to terminal state + await store.updateTaskStatus(createdTask2.taskId, 'completed'); + + // Fast-forward past original ttl + vi.advanceTimersByTime(2001); + + // Task should be cleaned up + task = await store.getTask(createdTask2.taskId); + expect(task).toBeNull(); + }); + + it('should return actual TTL in task response', async () => { + // Test that the TaskStore returns the actual TTL it will use + // This implementation uses the requested TTL as-is, but implementations + // MAY override it (e.g., enforce maximum TTL limits) + const requestedTtl = 5000; + const taskParams: TaskCreationParams = { + ttl: requestedTtl + }; + const createdTask = await store.createTask(taskParams, 1111, { + method: 'tools/call', + params: {} + }); + + // The returned task should include the actual TTL that will be used + expect(createdTask.ttl).toBe(requestedTtl); + + // Verify the task is cleaned up after the actual TTL + vi.advanceTimersByTime(requestedTtl + 1); + const task = await store.getTask(createdTask.taskId); + expect(task).toBeNull(); + }); + + it('should support omitted TTL for unlimited lifetime', async () => { + // Test that omitting TTL means unlimited lifetime (server returns null) + // Per spec: clients omit ttl to let server decide, server returns null for unlimited + const taskParams: TaskCreationParams = {}; + const createdTask = await store.createTask(taskParams, 2222, { + method: 'tools/call', + params: {} + }); + + // The returned task should have null TTL (unlimited) + expect(createdTask.ttl).toBeNull(); + + // Task should not be cleaned up even after a long time + vi.advanceTimersByTime(100_000); + const task = await store.getTask(createdTask.taskId); + expect(task).toBeDefined(); + expect(task?.taskId).toBe(createdTask.taskId); + }); + + it('should cleanup tasks regardless of status', async () => { + // Test that TTL cleanup happens regardless of task status + const taskParams: TaskCreationParams = { + ttl: 1000 + }; + + // Create tasks in different statuses + const workingTask = await store.createTask(taskParams, 3333, { + method: 'tools/call', + params: {} + }); + + const completedTask = await store.createTask(taskParams, 4444, { + method: 'tools/call', + params: {} + }); + await store.storeTaskResult(completedTask.taskId, 'completed', { + content: [{ type: 'text' as const, text: 'Done' }] + }); + + const failedTask = await store.createTask(taskParams, 5555, { + method: 'tools/call', + params: {} + }); + await store.storeTaskResult(failedTask.taskId, 'failed', { + content: [{ type: 'text' as const, text: 'Error' }] + }); + + // Fast-forward past TTL + vi.advanceTimersByTime(1001); + + // All tasks should be cleaned up regardless of status + expect(await store.getTask(workingTask.taskId)).toBeNull(); + expect(await store.getTask(completedTask.taskId)).toBeNull(); + expect(await store.getTask(failedTask.taskId)).toBeNull(); + }); + }); + + describe('getAllTasks', () => { + it('should return all tasks', async () => { + await store.createTask({}, 1, { + method: 'tools/call', + params: {} + }); + await store.createTask({}, 2, { + method: 'tools/call', + params: {} + }); + await store.createTask({}, 3, { + method: 'tools/call', + params: {} + }); + + const tasks = store.getAllTasks(); + expect(tasks).toHaveLength(3); + // Verify all tasks have unique IDs + const taskIds = tasks.map(t => t.taskId); + expect(new Set(taskIds).size).toBe(3); + }); + + it('should return empty array when no tasks', () => { + const tasks = store.getAllTasks(); + expect(tasks).toStrictEqual([]); + }); + }); + + describe('listTasks', () => { + it('should return empty list when no tasks', async () => { + const result = await store.listTasks(); + expect(result.tasks).toStrictEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should return all tasks when less than page size', async () => { + await store.createTask({}, 1, { + method: 'tools/call', + params: {} + }); + await store.createTask({}, 2, { + method: 'tools/call', + params: {} + }); + await store.createTask({}, 3, { + method: 'tools/call', + params: {} + }); + + const result = await store.listTasks(); + expect(result.tasks).toHaveLength(3); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should paginate when more than page size', async () => { + // Create 15 tasks (page size is 10) + for (let i = 1; i <= 15; i++) { + await store.createTask({}, i, { + method: 'tools/call', + params: {} + }); + } + + // Get first page + const page1 = await store.listTasks(); + expect(page1.tasks).toHaveLength(10); + expect(page1.nextCursor).toBeDefined(); + + // Get second page using cursor + const page2 = await store.listTasks(page1.nextCursor); + expect(page2.tasks).toHaveLength(5); + expect(page2.nextCursor).toBeUndefined(); + }); + + it('should throw error for invalid cursor', async () => { + await store.createTask({}, 1, { + method: 'tools/call', + params: {} + }); + + await expect(store.listTasks('non-existent-cursor')).rejects.toThrow('Invalid cursor: non-existent-cursor'); + }); + + it('should continue from cursor correctly', async () => { + // Create 5 tasks + for (let i = 1; i <= 5; i++) { + await store.createTask({}, i, { + method: 'tools/call', + params: {} + }); + } + + // Get first 3 tasks + const allTaskIds = store.getAllTasks().map(t => t.taskId); + const result = await store.listTasks(allTaskIds[2]); + + // Should get tasks after the third task + expect(result.tasks).toHaveLength(2); + }); + }); + + describe('session isolation', () => { + const baseRequest: Request = { method: 'tools/call', params: { name: 'demo' } }; + + it('should not allow session-b to list tasks created by session-a', async () => { + await store.createTask({}, 1, baseRequest, 'session-a'); + await store.createTask({}, 2, baseRequest, 'session-a'); + + const result = await store.listTasks(undefined, 'session-b'); + expect(result.tasks).toHaveLength(0); + }); + + it('should not allow session-b to read a task created by session-a', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + + const result = await store.getTask(task.taskId, 'session-b'); + expect(result).toBeNull(); + }); + + it('should not allow session-b to update a task created by session-a', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + + await expect(store.updateTaskStatus(task.taskId, 'cancelled', undefined, 'session-b')).rejects.toThrow('not found'); + }); + + it('should not allow session-b to store a result on session-a task', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + + await expect(store.storeTaskResult(task.taskId, 'completed', { content: [] }, 'session-b')).rejects.toThrow('not found'); + }); + + it('should not allow session-b to get the result of session-a task', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + await store.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'secret' }] }, 'session-a'); + + await expect(store.getTaskResult(task.taskId, 'session-b')).rejects.toThrow('not found'); + }); + + it('should allow the owning session to access its own tasks', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + + const retrieved = await store.getTask(task.taskId, 'session-a'); + expect(retrieved).toBeDefined(); + expect(retrieved?.taskId).toBe(task.taskId); + }); + + it('should list only tasks belonging to the requesting session', async () => { + await store.createTask({}, 1, baseRequest, 'session-a'); + await store.createTask({}, 2, baseRequest, 'session-b'); + await store.createTask({}, 3, baseRequest, 'session-a'); + + const resultA = await store.listTasks(undefined, 'session-a'); + expect(resultA.tasks).toHaveLength(2); + + const resultB = await store.listTasks(undefined, 'session-b'); + expect(resultB.tasks).toHaveLength(1); + }); + + it('should allow access when no sessionId is provided (backward compatibility)', async () => { + const task = await store.createTask({}, 1, baseRequest, 'session-a'); + + // No sessionId on read = no filtering + const retrieved = await store.getTask(task.taskId); + expect(retrieved).toBeDefined(); + }); + + it('should allow access when task was created without sessionId', async () => { + const task = await store.createTask({}, 1, baseRequest); + + // Any sessionId on read should still see the task + const retrieved = await store.getTask(task.taskId, 'session-b'); + expect(retrieved).toBeDefined(); + }); + + it('should paginate correctly within a session', async () => { + // Create 15 tasks for session-a, 5 for session-b + for (let i = 1; i <= 15; i++) { + await store.createTask({}, i, baseRequest, 'session-a'); + } + for (let i = 16; i <= 20; i++) { + await store.createTask({}, i, baseRequest, 'session-b'); + } + + // First page for session-a should have 10 + const page1 = await store.listTasks(undefined, 'session-a'); + expect(page1.tasks).toHaveLength(10); + expect(page1.nextCursor).toBeDefined(); + + // Second page for session-a should have 5 + const page2 = await store.listTasks(page1.nextCursor, 'session-a'); + expect(page2.tasks).toHaveLength(5); + expect(page2.nextCursor).toBeUndefined(); + + // session-b should only see its 5 + const resultB = await store.listTasks(undefined, 'session-b'); + expect(resultB.tasks).toHaveLength(5); + expect(resultB.nextCursor).toBeUndefined(); + }); + }); + + describe('cleanup', () => { + it('should clear all timers and tasks', async () => { + await store.createTask({ ttl: 1000 }, 1, { + method: 'tools/call', + params: {} + }); + await store.createTask({ ttl: 2000 }, 2, { + method: 'tools/call', + params: {} + }); + + expect(store.getAllTasks()).toHaveLength(2); + + store.cleanup(); + + expect(store.getAllTasks()).toHaveLength(0); + }); + }); +}); + +describe('InMemoryTaskMessageQueue', () => { + let queue: InMemoryTaskMessageQueue; + + beforeEach(() => { + queue = new InMemoryTaskMessageQueue(); + }); + + describe('enqueue and dequeue', () => { + it('should enqueue and dequeue request messages', async () => { + const requestMessage: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-1', requestMessage); + const dequeued = await queue.dequeue('task-1'); + + expect(dequeued).toStrictEqual(requestMessage); + }); + + it('should enqueue and dequeue notification messages', async () => { + const notificationMessage: QueuedMessage = { + type: 'notification', + message: { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progress: 50, total: 100 } + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-2', notificationMessage); + const dequeued = await queue.dequeue('task-2'); + + expect(dequeued).toStrictEqual(notificationMessage); + }); + + it('should enqueue and dequeue response messages', async () => { + const responseMessage: QueuedMessage = { + type: 'response', + message: { + jsonrpc: '2.0', + id: 42, + result: { content: [{ type: 'text', text: 'Success' }] } + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-3', responseMessage); + const dequeued = await queue.dequeue('task-3'); + + expect(dequeued).toStrictEqual(responseMessage); + }); + + it('should return undefined when dequeuing from empty queue', async () => { + const dequeued = await queue.dequeue('task-empty'); + expect(dequeued).toBeUndefined(); + }); + + it('should maintain FIFO order for mixed message types', async () => { + const request: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: {} + }, + timestamp: 1000 + }; + + const notification: QueuedMessage = { + type: 'notification', + message: { + jsonrpc: '2.0', + method: 'notifications/progress', + params: {} + }, + timestamp: 2000 + }; + + const response: QueuedMessage = { + type: 'response', + message: { + jsonrpc: '2.0', + id: 1, + result: {} + }, + timestamp: 3000 + }; + + await queue.enqueue('task-fifo', request); + await queue.enqueue('task-fifo', notification); + await queue.enqueue('task-fifo', response); + + expect(await queue.dequeue('task-fifo')).toStrictEqual(request); + expect(await queue.dequeue('task-fifo')).toStrictEqual(notification); + expect(await queue.dequeue('task-fifo')).toStrictEqual(response); + expect(await queue.dequeue('task-fifo')).toBeUndefined(); + }); + }); + + describe('dequeueAll', () => { + it('should dequeue all messages including responses', async () => { + const request: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: {} + }, + timestamp: 1000 + }; + + const response: QueuedMessage = { + type: 'response', + message: { + jsonrpc: '2.0', + id: 1, + result: {} + }, + timestamp: 2000 + }; + + const notification: QueuedMessage = { + type: 'notification', + message: { + jsonrpc: '2.0', + method: 'notifications/progress', + params: {} + }, + timestamp: 3000 + }; + + await queue.enqueue('task-all', request); + await queue.enqueue('task-all', response); + await queue.enqueue('task-all', notification); + + const all = await queue.dequeueAll('task-all'); + + expect(all).toHaveLength(3); + expect(all[0]).toStrictEqual(request); + expect(all[1]).toStrictEqual(response); + expect(all[2]).toStrictEqual(notification); + }); + + it('should return empty array for non-existent task', async () => { + const all = await queue.dequeueAll('non-existent'); + expect(all).toStrictEqual([]); + }); + + it('should clear the queue after dequeueAll', async () => { + const message: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'test', + params: {} + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-clear', message); + await queue.dequeueAll('task-clear'); + + const dequeued = await queue.dequeue('task-clear'); + expect(dequeued).toBeUndefined(); + }); + }); + + describe('queue size limits', () => { + it('should throw when maxSize is exceeded', async () => { + const message: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'test', + params: {} + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-limit', message, undefined, 2); + await queue.enqueue('task-limit', message, undefined, 2); + + await expect(queue.enqueue('task-limit', message, undefined, 2)).rejects.toThrow('Task message queue overflow'); + }); + + it('should allow enqueue when under maxSize', async () => { + const message: QueuedMessage = { + type: 'response', + message: { + jsonrpc: '2.0', + id: 1, + result: {} + }, + timestamp: Date.now() + }; + + await expect(queue.enqueue('task-ok', message, undefined, 5)).resolves.toBeUndefined(); + }); + }); + + describe('task isolation', () => { + it('should isolate messages between different tasks', async () => { + const message1: QueuedMessage = { + type: 'request', + message: { + jsonrpc: '2.0', + id: 1, + method: 'test1', + params: {} + }, + timestamp: 1000 + }; + + const message2: QueuedMessage = { + type: 'response', + message: { + jsonrpc: '2.0', + id: 2, + result: {} + }, + timestamp: 2000 + }; + + await queue.enqueue('task-a', message1); + await queue.enqueue('task-b', message2); + + expect(await queue.dequeue('task-a')).toStrictEqual(message1); + expect(await queue.dequeue('task-b')).toStrictEqual(message2); + expect(await queue.dequeue('task-a')).toBeUndefined(); + expect(await queue.dequeue('task-b')).toBeUndefined(); + }); + }); + + describe('response message error handling', () => { + it('should handle response messages with errors', async () => { + const errorResponse: QueuedMessage = { + type: 'error', + message: { + jsonrpc: '2.0', + id: 1, + error: { + code: -32_600, + message: 'Invalid Request' + } + }, + timestamp: Date.now() + }; + + await queue.enqueue('task-error', errorResponse); + const dequeued = await queue.dequeue('task-error'); + + expect(dequeued).toStrictEqual(errorResponse); + expect(dequeued?.type).toBe('error'); + }); + }); +}); diff --git a/packages/core/test/inMemory.test.ts b/packages/core/test/inMemory.test.ts new file mode 100644 index 0000000..46332ea --- /dev/null +++ b/packages/core/test/inMemory.test.ts @@ -0,0 +1,165 @@ +import type { AuthInfo, JSONRPCMessage } from '../src/types/index.js'; +import { InMemoryTransport } from '../src/util/inMemory.js'; + +describe('InMemoryTransport', () => { + let clientTransport: InMemoryTransport; + let serverTransport: InMemoryTransport; + + beforeEach(() => { + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + test('should create linked pair', () => { + expect(clientTransport).toBeDefined(); + expect(serverTransport).toBeDefined(); + }); + + test('should start without error', async () => { + await expect(clientTransport.start()).resolves.not.toThrow(); + await expect(serverTransport.start()).resolves.not.toThrow(); + }); + + test('should send message from client to server', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 1 + }; + + let receivedMessage: JSONRPCMessage | undefined; + serverTransport.onmessage = msg => { + receivedMessage = msg; + }; + + await clientTransport.send(message); + expect(receivedMessage).toEqual(message); + }); + + test('should send message with auth info from client to server', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 1 + }; + + const authInfo: AuthInfo = { + token: 'test-token', + clientId: 'test-client', + scopes: ['read', 'write'], + expiresAt: Date.now() / 1000 + 3600 + }; + + let receivedMessage: JSONRPCMessage | undefined; + let receivedAuthInfo: AuthInfo | undefined; + serverTransport.onmessage = (msg, extra) => { + receivedMessage = msg; + receivedAuthInfo = extra?.authInfo; + }; + + await clientTransport.send(message, { authInfo }); + expect(receivedMessage).toEqual(message); + expect(receivedAuthInfo).toEqual(authInfo); + }); + + test('should send message from server to client', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 1 + }; + + let receivedMessage: JSONRPCMessage | undefined; + clientTransport.onmessage = msg => { + receivedMessage = msg; + }; + + await serverTransport.send(message); + expect(receivedMessage).toEqual(message); + }); + + test('should handle close', async () => { + let clientClosed = false; + let serverClosed = false; + + clientTransport.onclose = () => { + clientClosed = true; + }; + + serverTransport.onclose = () => { + serverClosed = true; + }; + + await clientTransport.close(); + expect(clientClosed).toBe(true); + expect(serverClosed).toBe(true); + }); + + test('should throw error when sending after close', async () => { + await clientTransport.close(); + await expect(clientTransport.send({ jsonrpc: '2.0', method: 'test', id: 1 })).rejects.toThrow('Not connected'); + }); + + test('should fire onclose exactly once per transport', async () => { + let clientCloseCount = 0; + let serverCloseCount = 0; + + clientTransport.onclose = () => clientCloseCount++; + serverTransport.onclose = () => serverCloseCount++; + + await clientTransport.close(); + + expect(clientCloseCount).toBe(1); + expect(serverCloseCount).toBe(1); + }); + + test('should handle double close idempotently', async () => { + let clientCloseCount = 0; + clientTransport.onclose = () => clientCloseCount++; + + await clientTransport.close(); + await clientTransport.close(); + + expect(clientCloseCount).toBe(1); + }); + + test('should handle concurrent close from both sides', async () => { + let clientCloseCount = 0; + let serverCloseCount = 0; + + clientTransport.onclose = () => clientCloseCount++; + serverTransport.onclose = () => serverCloseCount++; + + await Promise.all([clientTransport.close(), serverTransport.close()]); + + expect(clientCloseCount).toBe(1); + expect(serverCloseCount).toBe(1); + }); + + test('should fire onclose even if peer onclose throws', async () => { + let clientCloseCount = 0; + clientTransport.onclose = () => clientCloseCount++; + serverTransport.onclose = () => { + throw new Error('boom'); + }; + + await expect(clientTransport.close()).rejects.toThrow('boom'); + expect(clientCloseCount).toBe(1); + }); + + test('should queue messages sent before start', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + id: 1 + }; + + let receivedMessage: JSONRPCMessage | undefined; + serverTransport.onmessage = msg => { + receivedMessage = msg; + }; + + await clientTransport.send(message); + await serverTransport.start(); + expect(receivedMessage).toEqual(message); + }); +}); diff --git a/packages/core/test/shared/auth.test.ts b/packages/core/test/shared/auth.test.ts new file mode 100644 index 0000000..770e0c4 --- /dev/null +++ b/packages/core/test/shared/auth.test.ts @@ -0,0 +1,122 @@ +import { + OAuthClientMetadataSchema, + OAuthMetadataSchema, + OpenIdProviderMetadataSchema, + OptionalSafeUrlSchema, + SafeUrlSchema +} from '../../src/shared/auth.js'; + +describe('SafeUrlSchema', () => { + it('accepts valid HTTPS URLs', () => { + expect(SafeUrlSchema.parse('https://example.com')).toBe('https://example.com'); + expect(SafeUrlSchema.parse('https://auth.example.com/oauth/authorize')).toBe('https://auth.example.com/oauth/authorize'); + }); + + it('accepts valid HTTP URLs', () => { + expect(SafeUrlSchema.parse('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + it('rejects javascript: scheme URLs', () => { + expect(() => SafeUrlSchema.parse('javascript:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + expect(() => SafeUrlSchema.parse('JAVASCRIPT:alert(1)')).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); + + it('rejects invalid URLs', () => { + expect(() => SafeUrlSchema.parse('not-a-url')).toThrow(); + expect(() => SafeUrlSchema.parse('')).toThrow(); + }); + + it('works with safeParse', () => { + expect(() => SafeUrlSchema.safeParse('not-a-url')).not.toThrow(); + }); +}); + +describe('OptionalSafeUrlSchema', () => { + it('accepts empty string and transforms it to undefined', () => { + expect(OptionalSafeUrlSchema.parse('')).toBe(undefined); + }); +}); + +describe('OAuthMetadataSchema', () => { + it('validates complete OAuth metadata', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + response_types_supported: ['code'], + scopes_supported: ['read', 'write'] + }; + + expect(() => OAuthMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects metadata with javascript: URLs', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'javascript:alert(1)', + token_endpoint: 'https://auth.example.com/oauth/token', + response_types_supported: ['code'] + }; + + expect(() => OAuthMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); + + it('requires mandatory fields', () => { + const incompleteMetadata = { + issuer: 'https://auth.example.com' + }; + + expect(() => OAuthMetadataSchema.parse(incompleteMetadata)).toThrow(); + }); +}); + +describe('OpenIdProviderMetadataSchema', () => { + it('validates complete OpenID Provider metadata', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + jwks_uri: 'https://auth.example.com/.well-known/jwks.json', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }; + + expect(() => OpenIdProviderMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects metadata with javascript: in jwks_uri', () => { + const metadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + jwks_uri: 'javascript:alert(1)', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }; + + expect(() => OpenIdProviderMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); +}); + +describe('OAuthClientMetadataSchema', () => { + it('validates client metadata with safe URLs', () => { + const metadata = { + redirect_uris: ['https://app.example.com/callback'], + client_name: 'Test App', + client_uri: 'https://app.example.com' + }; + + expect(() => OAuthClientMetadataSchema.parse(metadata)).not.toThrow(); + }); + + it('rejects client metadata with javascript: redirect URIs', () => { + const metadata = { + redirect_uris: ['javascript:alert(1)'], + client_name: 'Test App' + }; + + expect(() => OAuthClientMetadataSchema.parse(metadata)).toThrow('URL cannot use javascript:, data:, or vbscript: scheme'); + }); +}); diff --git a/packages/core/test/shared/authUtils.test.ts b/packages/core/test/shared/authUtils.test.ts new file mode 100644 index 0000000..902cfe4 --- /dev/null +++ b/packages/core/test/shared/authUtils.test.ts @@ -0,0 +1,90 @@ +import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../src/shared/authUtils.js'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe( + 'https://example.com/path?query=1' + ); + }); + + it('should return URL unchanged if no fragment', () => { + expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); + // Ports preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe( + 'https://example.com/?foo=bar&baz=qux' + ); + // Trailing slashes preserved + expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); + }); + }); + + describe('resourceMatches', () => { + it('should match identical URLs', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.com/path' }) + ).toBe(true); + expect(checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/' })).toBe( + true + ); + }); + + it('should not match URLs with different paths', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/path1', configuredResource: 'https://example.com/path2' }) + ).toBe(false); + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/', configuredResource: 'https://example.com/path' }) + ).toBe(false); + }); + + it('should not match URLs with different domains', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/path', configuredResource: 'https://example.org/path' }) + ).toBe(false); + }); + + it('should not match URLs with different ports', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com:8080/path', configuredResource: 'https://example.com/path' }) + ).toBe(false); + }); + + it('should not match URLs where one path is a sub-path of another', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/mcpxxxx', configuredResource: 'https://example.com/mcp' }) + ).toBe(false); + expect( + checkResourceAllowed({ + requestedResource: 'https://example.com/folder', + configuredResource: 'https://example.com/folder/subfolder' + }) + ).toBe(false); + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/api/v1', configuredResource: 'https://example.com/api' }) + ).toBe(true); + }); + + it('should handle trailing slashes vs no trailing slashes', () => { + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/mcp/', configuredResource: 'https://example.com/mcp' }) + ).toBe(true); + expect( + checkResourceAllowed({ requestedResource: 'https://example.com/folder', configuredResource: 'https://example.com/folder/' }) + ).toBe(false); + }); + }); +}); diff --git a/packages/core/test/shared/customMethods.test.ts b/packages/core/test/shared/customMethods.test.ts new file mode 100644 index 0000000..47e02c9 --- /dev/null +++ b/packages/core/test/shared/customMethods.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; + +import { Protocol } from '../../src/shared/protocol.js'; +import type { BaseContext, JSONRPCRequest, Result, StandardSchemaV1 } from '../../src/exports/public/index.js'; +import { ProtocolError } from '../../src/types/index.js'; +import { SdkErrorCode } from '../../src/errors/sdkErrors.js'; +import { InMemoryTransport } from '../../src/util/inMemory.js'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} +} + +async function pair(): Promise<[TestProtocol, TestProtocol]> { + const [t1, t2] = InMemoryTransport.createLinkedPair(); + const a = new TestProtocol(); + const b = new TestProtocol(); + await a.connect(t1); + await b.connect(t2); + return [a, b]; +} + +describe('Protocol custom-method support', () => { + describe('setRequestHandler 3-arg form', () => { + const SearchParams = z.object({ query: z.string(), limit: z.number().int() }); + const SearchResult = z.object({ items: z.array(z.string()) }); + + it('registers, validates params, and handler receives parsed params', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, _ctx) => { + expect(params.query).toBe('hello'); + expect(params.limit).toBe(5); + return { items: [`result for ${params.query}`] }; + }); + + const result = await a.request({ method: 'acme/search', params: { query: 'hello', limit: 5 } }, SearchResult); + expect(result.items).toEqual(['result for hello']); + }); + + it('strips _meta from params before validation', async () => { + const [a, b] = await pair(); + const Strict = z.strictObject({ x: z.number() }); + b.setRequestHandler('acme/strict', { params: Strict }, async params => { + expect(params).toEqual({ x: 1 }); + return {}; + }); + + const result = await a.request({ method: 'acme/strict', params: { x: 1, _meta: { progressToken: 't' } } }, z.object({})); + expect(result).toEqual({}); + }); + + it('rejects invalid params with ProtocolError(InvalidParams)', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/search', { params: SearchParams }, async () => ({})); + + await expect(a.request({ method: 'acme/search', params: { query: 'q', limit: 'oops' } }, z.object({}))).rejects.toThrow( + ProtocolError + ); + }); + + it('types handler return from schemas.result', () => { + const p = new TestProtocol(); + p.setRequestHandler('acme/typed', { params: z.object({}), result: SearchResult }, async () => { + return { items: [] }; + }); + // @ts-expect-error wrong return shape when result schema supplied + p.setRequestHandler('acme/typed', { params: z.object({}), result: SearchResult }, async () => ({})); + // No result schema → handler may return any Result + p.setRequestHandler('acme/loose', { params: z.object({}) }, async () => ({}) as Result); + }); + + it('throws TypeError when 2-arg form is used with a non-spec method', () => { + const p = new TestProtocol(); + expect(() => p.setRequestHandler('acme/unknown' as never, () => ({}) as never)).toThrow(TypeError); + }); + + it('routes both 2-arg and 3-arg registration through _wrapHandler', () => { + const seen: string[] = []; + class SpyProtocol extends TestProtocol { + protected override _wrapHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise + ): (request: JSONRPCRequest, ctx: BaseContext) => Promise { + seen.push(method); + return handler; + } + } + const p = new SpyProtocol(); + p.setRequestHandler('tools/list', () => ({ tools: [] })); + p.setRequestHandler('acme/custom', { params: z.object({}) }, () => ({})); + expect(seen).toContain('tools/list'); + expect(seen).toContain('acme/custom'); + }); + }); + + describe('setNotificationHandler 3-arg form', () => { + it('registers, validates params, handler receives parsed params', async () => { + const [a, b] = await pair(); + const Progress = z.object({ stage: z.string(), pct: z.number() }); + const seen: Array> = []; + b.setNotificationHandler('acme/searchProgress', { params: Progress }, params => { + seen.push(params); + }); + + await a.notification({ method: 'acme/searchProgress', params: { stage: 'fetch', pct: 0.5 } }); + await new Promise(r => setTimeout(r, 0)); + expect(seen).toEqual([{ stage: 'fetch', pct: 0.5 }]); + }); + + it('passes the raw notification (with _meta) as the second handler argument', async () => { + const [a, b] = await pair(); + const Strict = z.strictObject({ stage: z.string() }); + let seenMeta: unknown; + b.setNotificationHandler('acme/searchProgress', { params: Strict }, (params, notification) => { + expect(params).toEqual({ stage: 'fetch' }); + seenMeta = notification.params?._meta; + }); + + await a.notification({ method: 'acme/searchProgress', params: { stage: 'fetch', _meta: { traceId: 't1' } } }); + await new Promise(r => setTimeout(r, 0)); + expect(seenMeta).toEqual({ traceId: 't1' }); + }); + }); + + describe('request() schema overload', () => { + it('validates result against provided schema and types the return', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/echo', { params: z.object({ v: z.string() }) }, async params => ({ echoed: params.v })); + + const result = await a.request({ method: 'acme/echo', params: { v: 'x' } }, z.object({ echoed: z.string() })); + expect(result.echoed).toBe('x'); + }); + + it('throws TypeError when 1-arg form is used with a non-spec method', async () => { + const [a] = await pair(); + expect(() => a.request({ method: 'acme/unknown' } as never)).toThrow(TypeError); + }); + + it('rejects with SdkError(InvalidResult) when the response fails the result schema', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/bad', { params: z.object({}) }, async () => ({ wrong: 123 })); + + await expect(a.request({ method: 'acme/bad', params: {} }, z.object({ echoed: z.string() }))).rejects.toMatchObject({ + code: SdkErrorCode.InvalidResult + }); + }); + + it('returns the result (and sends no cancellation) if the signal aborts during async result-schema validation', async () => { + const [a, b] = await pair(); + b.setRequestHandler('acme/echo', { params: z.object({}) }, async () => ({ echoed: 'ok' })); + + const cancelled: unknown[] = []; + b.setNotificationHandler('notifications/cancelled', n => { + cancelled.push(n); + }); + + const ac = new AbortController(); + const AsyncEcho: StandardSchemaV1 = { + '~standard': { + version: 1, + vendor: 'test', + validate: value => + new Promise(r => { + ac.abort(); + setTimeout(() => r({ value: value as { echoed: string } }), 0); + }) + } + }; + + const result = await a.request({ method: 'acme/echo', params: {} }, AsyncEcho, { signal: ac.signal }); + expect(result).toEqual({ echoed: 'ok' }); + await new Promise(r => setTimeout(r, 0)); + expect(cancelled).toHaveLength(0); + }); + }); + + describe('ctx.mcpReq.send schema overload', () => { + it('sends a related custom-method request from within a handler', async () => { + const [a, b] = await pair(); + const Pong = z.object({ pong: z.literal(true) }); + + a.setRequestHandler('acme/pong', { params: z.object({}) }, async () => ({ pong: true as const })); + b.setRequestHandler('acme/ping', { params: z.object({}) }, async (_params, ctx) => { + const r = await ctx.mcpReq.send({ method: 'acme/pong', params: {} }, Pong); + expect(r.pong).toBe(true); + return { ok: true }; + }); + + const result = await a.request({ method: 'acme/ping', params: {} }, z.object({ ok: z.boolean() })); + expect(result.ok).toBe(true); + }); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts new file mode 100644 index 0000000..619e093 --- /dev/null +++ b/packages/core/test/shared/protocol.test.ts @@ -0,0 +1,5680 @@ +import type { MockInstance } from 'vitest'; +import { vi } from 'vitest'; +import * as z from 'zod/v4'; +import type { ZodType } from 'zod/v4'; + +import type { + QueuedMessage, + QueuedNotification, + QueuedRequest, + TaskMessageQueue, + TaskStore +} from '../../src/experimental/tasks/interfaces.js'; +import { InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/inMemory.js'; +import type { BaseContext } from '../../src/shared/protocol.js'; +import { mergeCapabilities, Protocol } from '../../src/shared/protocol.js'; +import type { ErrorMessage, ResponseMessage } from '../../src/shared/responseMessage.js'; +import { toArrayAsync } from '../../src/shared/responseMessage.js'; +import type { TaskManagerOptions } from '../../src/shared/taskManager.js'; +import { NullTaskManager, TaskManager } from '../../src/shared/taskManager.js'; +import type { Transport, TransportSendOptions } from '../../src/shared/transport.js'; +import type { + ClientCapabilities, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + JSONRPCResultResponse, + Notification, + Request, + RequestId, + Result, + ServerCapabilities, + Task, + TaskCreationParams +} from '../../src/types/index.js'; +import { ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; + +// Test Protocol subclass for testing +class TestProtocolImpl extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +function createTestProtocol(taskOptions?: TaskManagerOptions): TestProtocolImpl { + return new TestProtocolImpl(taskOptions ? { tasks: taskOptions } : undefined); +} + +// Type helper for accessing private/protected Protocol properties in tests +interface TestProtocolInternals { + _responseHandlers: Map void>; + _taskManager: { + _taskMessageQueue?: TaskMessageQueue; + _requestResolvers: Map void>; + _taskProgressTokens: Map; + _clearTaskQueue: (taskId: string, sessionId?: string) => Promise; + listTasks: (params?: { cursor?: string }) => Promise<{ tasks: Task[]; nextCursor?: string }>; + cancelTask: (params: { taskId: string }) => Promise; + requestStream: (request: Request, schema: ZodType, options?: unknown) => AsyncGenerator>; + }; +} + +// Mock Transport class +class MockTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(_message: JSONRPCMessage, _options?: TransportSendOptions): Promise {} +} + +function createMockTaskStore(options?: { + onStatus?: (status: Task['status']) => void; + onList?: () => void; +}): TaskStore & { [K in keyof TaskStore]: MockInstance } { + const tasks: Record = {}; + return { + createTask: vi.fn((taskParams: TaskCreationParams, _1: RequestId, _2: Request) => { + // Generate a unique task ID + const taskId = `test-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const createdAt = new Date().toISOString(); + const task = (tasks[taskId] = { + taskId, + status: 'working', + ttl: taskParams.ttl ?? null, + createdAt, + lastUpdatedAt: createdAt, + pollInterval: taskParams.pollInterval ?? 1000 + }); + options?.onStatus?.('working'); + return Promise.resolve(task); + }), + getTask: vi.fn((taskId: string) => { + return Promise.resolve(tasks[taskId] ?? null); + }), + updateTaskStatus: vi.fn((taskId, status, statusMessage) => { + const task = tasks[taskId]; + if (task) { + task.status = status; + task.statusMessage = statusMessage; + options?.onStatus?.(task.status); + } + return Promise.resolve(); + }), + storeTaskResult: vi.fn((taskId: string, status: 'completed' | 'failed', result: Result) => { + const task = tasks[taskId]; + if (task) { + task.status = status; + task.result = result; + options?.onStatus?.(status); + } + return Promise.resolve(); + }), + getTaskResult: vi.fn((taskId: string) => { + const task = tasks[taskId]; + if (task?.result) { + return Promise.resolve(task.result); + } + throw new Error('Task result not found'); + }), + listTasks: vi.fn(() => { + const result = { + tasks: Object.values(tasks) + }; + options?.onList?.(); + return Promise.resolve(result); + }) + }; +} + +function createLatch() { + let latch = false; + const waitForLatch = async () => { + while (!latch) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + }; + + return { + releaseLatch: () => { + latch = true; + }, + waitForLatch + }; +} + +function assertErrorResponse(o: ResponseMessage): asserts o is ErrorMessage { + expect(o.type).toBe('error'); +} + +function assertQueuedNotification(o?: QueuedMessage): asserts o is QueuedNotification { + expect(o).toBeDefined(); + expect(o?.type).toBe('notification'); +} + +function assertQueuedRequest(o?: QueuedMessage): asserts o is QueuedRequest { + expect(o).toBeDefined(); + expect(o?.type).toBe('request'); +} + +/** + * Helper to call the protected _requestWithSchema method from tests that + * use custom method names not present in RequestMethod. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function testRequest(proto: Protocol, request: Request, resultSchema: ZodType, options?: any) { + return ( + proto as unknown as { _requestWithSchema: (request: Request, resultSchema: ZodType, options?: unknown) => Promise } + )._requestWithSchema(request, resultSchema, options); +} + +describe('protocol tests', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = createTestProtocol(); + }); + + test('should throw a timeout error if the request exceeds the timeout', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + try { + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + await testRequest(protocol, request, mockSchema, { + timeout: 0 + }); + } catch (error) { + expect(error).toBeInstanceOf(SdkError); + if (error instanceof SdkError) { + expect(error.code).toBe(SdkErrorCode.RequestTimeout); + } + } + }); + + test('should invoke onclose when the connection is closed', async () => { + const oncloseMock = vi.fn(); + protocol.onclose = oncloseMock; + await protocol.connect(transport); + await transport.close(); + expect(oncloseMock).toHaveBeenCalled(); + }); + + test('should abort in-flight request handlers when the connection is closed', async () => { + await protocol.connect(transport); + + let abortReason: unknown; + let handlerStarted = false; + const handlerDone = new Promise(resolve => { + protocol.setRequestHandler('ping', async (_request, ctx) => { + handlerStarted = true; + await new Promise(resolveInner => { + ctx.mcpReq.signal.addEventListener('abort', () => { + abortReason = ctx.mcpReq.signal.reason; + resolveInner(); + }); + }); + resolve(); + return {}; + }); + }); + + transport.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + + await vi.waitFor(() => expect(handlerStarted).toBe(true)); + + await transport.close(); + await handlerDone; + + expect(abortReason).toBeInstanceOf(SdkError); + expect((abortReason as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); + }); + + test('should remove abort listener from caller signal when request settles', async () => { + await protocol.connect(transport); + + const controller = new AbortController(); + const addSpy = vi.spyOn(controller.signal, 'addEventListener'); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + const mockSchema = z.object({ result: z.string() }); + const reqPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { + signal: controller.signal + }); + + expect(addSpy).toHaveBeenCalledTimes(1); + const listener = addSpy.mock.calls[0]![1]; + + transport.onmessage?.({ jsonrpc: '2.0', id: 0, result: { result: 'ok' } }); + await reqPromise; + + expect(removeSpy).toHaveBeenCalledWith('abort', listener); + }); + + test('should not accumulate abort listeners when reusing a signal across requests', async () => { + await protocol.connect(transport); + + const controller = new AbortController(); + const addSpy = vi.spyOn(controller.signal, 'addEventListener'); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + const mockSchema = z.object({ result: z.string() }); + for (let i = 0; i < 5; i++) { + const reqPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { + signal: controller.signal + }); + transport.onmessage?.({ jsonrpc: '2.0', id: i, result: { result: 'ok' } }); + await reqPromise; + } + + expect(addSpy).toHaveBeenCalledTimes(5); + expect(removeSpy).toHaveBeenCalledTimes(5); + }); + + test('should remove abort listener when request rejects', async () => { + await protocol.connect(transport); + + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, 'removeEventListener'); + + const mockSchema = z.object({ result: z.string() }); + await expect( + testRequest(protocol, { method: 'example', params: {} }, mockSchema, { + signal: controller.signal, + timeout: 0 + }) + ).rejects.toThrow(); + + expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + test('should not overwrite existing hooks when connecting transports', async () => { + const oncloseMock = vi.fn(); + const onerrorMock = vi.fn(); + const onmessageMock = vi.fn(); + transport.onclose = oncloseMock; + transport.onerror = onerrorMock; + transport.onmessage = onmessageMock; + await protocol.connect(transport); + transport.onclose(); + transport.onerror(new Error()); + transport.onmessage(''); + expect(oncloseMock).toHaveBeenCalled(); + expect(onerrorMock).toHaveBeenCalled(); + expect(onmessageMock).toHaveBeenCalled(); + }); + + describe('_meta preservation with onprogress', () => { + test('should preserve existing _meta when adding progressToken', async () => { + await protocol.connect(transport); + const request = { + method: 'example', + params: { + data: 'test', + _meta: { + customField: 'customValue', + anotherField: 123 + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + + // Start request but don't await - we're testing the sent message + void testRequest(protocol, request, mockSchema, { + onprogress: onProgressMock + }).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'example', + params: { + data: 'test', + _meta: { + customField: 'customValue', + anotherField: 123, + progressToken: expect.any(Number) + } + }, + jsonrpc: '2.0', + id: expect.any(Number) + }), + expect.any(Object) + ); + }); + + test('should create _meta with progressToken when no _meta exists', async () => { + await protocol.connect(transport); + const request = { + method: 'example', + params: { + data: 'test' + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + + // Start request but don't await - we're testing the sent message + void testRequest(protocol, request, mockSchema, { + onprogress: onProgressMock + }).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'example', + params: { + data: 'test', + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: '2.0', + id: expect.any(Number) + }), + expect.any(Object) + ); + }); + + test('should not modify _meta when onprogress is not provided', async () => { + await protocol.connect(transport); + const request = { + method: 'example', + params: { + data: 'test', + _meta: { + customField: 'customValue' + } + } + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + + // Start request but don't await - we're testing the sent message + void testRequest(protocol, request, mockSchema).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'example', + params: { + data: 'test', + _meta: { + customField: 'customValue' + } + }, + jsonrpc: '2.0', + id: expect.any(Number) + }), + expect.any(Object) + ); + }); + + test('should handle params being undefined with onprogress', async () => { + await protocol.connect(transport); + const request = { + method: 'example' + }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + + // Start request but don't await - we're testing the sent message + void testRequest(protocol, request, mockSchema, { + onprogress: onProgressMock + }).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'example', + params: { + _meta: { + progressToken: expect.any(Number) + } + }, + jsonrpc: '2.0', + id: expect.any(Number) + }), + expect.any(Object) + ); + }); + }); + + describe('progress notification timeout behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('should not reset timeout when resetTimeoutOnProgress is false', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 1000, + resetTimeoutOnProgress: false, + onprogress: onProgressMock + }); + + vi.advanceTimersByTime(800); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 50, + total: 100 + } + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 50, + total: 100 + }); + + vi.advanceTimersByTime(201); + + await expect(requestPromise).rejects.toThrow('Request timed out'); + }); + + test('should reset timeout when progress notification is received', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 1000, + resetTimeoutOnProgress: true, + onprogress: onProgressMock + }); + vi.advanceTimersByTime(800); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 50, + total: 100 + } + }); + } + await Promise.resolve(); + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 50, + total: 100 + }); + vi.advanceTimersByTime(800); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 0, + result: { result: 'success' } + }); + } + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: 'success' }); + }); + + test('should respect maxTotalTimeout', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 1000, + maxTotalTimeout: 150, + resetTimeoutOnProgress: true, + onprogress: onProgressMock + }); + + // First progress notification should work + vi.advanceTimersByTime(80); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 50, + total: 100 + } + }); + } + await Promise.resolve(); + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 50, + total: 100 + }); + vi.advanceTimersByTime(80); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 75, + total: 100 + } + }); + } + await expect(requestPromise).rejects.toThrow('Maximum total timeout exceeded'); + expect(onProgressMock).toHaveBeenCalledTimes(1); + }); + + test('should timeout if no progress received within timeout period', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 100, + resetTimeoutOnProgress: true + }); + vi.advanceTimersByTime(101); + await expect(requestPromise).rejects.toThrow('Request timed out'); + }); + + test('should handle multiple progress notifications correctly', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 1000, + resetTimeoutOnProgress: true, + onprogress: onProgressMock + }); + + // Simulate multiple progress updates + for (let i = 1; i <= 3; i++) { + vi.advanceTimersByTime(800); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: i * 25, + total: 100 + } + }); + } + await Promise.resolve(); + expect(onProgressMock).toHaveBeenNthCalledWith(i, { + progress: i * 25, + total: 100 + }); + } + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 0, + result: { result: 'success' } + }); + } + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: 'success' }); + }); + + test('should handle progress notifications with message field', async () => { + await protocol.connect(transport); + const request = { method: 'example', params: {} }; + const mockSchema: ZodType<{ result: string }> = z.object({ + result: z.string() + }); + const onProgressMock = vi.fn(); + + const requestPromise = testRequest(protocol, request, mockSchema, { + timeout: 1000, + onprogress: onProgressMock + }); + + vi.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 25, + total: 100, + message: 'Initializing process...' + } + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 25, + total: 100, + message: 'Initializing process...' + }); + + vi.advanceTimersByTime(200); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken: 0, + progress: 75, + total: 100, + message: 'Processing data...' + } + }); + } + await Promise.resolve(); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 75, + total: 100, + message: 'Processing data...' + }); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 0, + result: { result: 'success' } + }); + } + await Promise.resolve(); + await expect(requestPromise).resolves.toEqual({ result: 'success' }); + }); + }); + + describe('Debounced Notifications', () => { + // We need to flush the microtask queue to test the debouncing logic. + // This helper function does that. + const flushMicrotasks = () => new Promise(resolve => setImmediate(resolve)); + + it('should NOT debounce a notification that has parameters', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced_with_params'] }); + await protocol.connect(transport); + + // ACT + // These notifications are configured for debouncing but contain params, so they should be sent immediately. + await protocol.notification({ method: 'test/debounced_with_params', params: { data: 1 } }); + await protocol.notification({ method: 'test/debounced_with_params', params: { data: 2 } }); + + // ASSERT + // Both should have been sent immediately to avoid data loss. + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 1 } }), undefined); + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ params: { data: 2 } }), undefined); + }); + + it('should NOT debounce a notification that has a relatedRequestId', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced_with_options'] }); + await protocol.connect(transport); + + // ACT + await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-1' }); + await protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 'req-2' }); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(2); + expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-1' }); + expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' }); + }); + + it('should clear pending debounced notifications on connection close', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + // Schedule a notification but don't flush the microtask queue. + protocol.notification({ method: 'test/debounced' }); + + // Close the connection. This should clear the pending set. + await protocol.close(); + + // Now, flush the microtask queue. + await flushMicrotasks(); + + // ASSERT + // The send should never have happened because the transport was cleared. + expect(sendSpy).not.toHaveBeenCalled(); + }); + + it('should debounce multiple synchronous calls when params property is omitted', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + // This is the more idiomatic way to write a notification with no params. + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + + expect(sendSpy).not.toHaveBeenCalled(); + await flushMicrotasks(); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(1); + // The final sent object might not even have the `params` key, which is fine. + // We can check that it was called and that the params are "falsy". + const sentNotification = sendSpy.mock.calls[0]![0]; + expect(sentNotification.method).toBe('test/debounced'); + expect(sentNotification.params).toBeUndefined(); + }); + + it('should debounce calls when params is explicitly undefined', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT + protocol.notification({ method: 'test/debounced', params: undefined }); + protocol.notification({ method: 'test/debounced', params: undefined }); + await flushMicrotasks(); + + // ASSERT + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'test/debounced', + params: undefined + }), + undefined + ); + }); + + it('should send non-debounced notifications immediately and multiple times', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] }); // Configure for a different method + await protocol.connect(transport); + + // ACT + // Call a non-debounced notification method multiple times. + await protocol.notification({ method: 'test/immediate' }); + await protocol.notification({ method: 'test/immediate' }); + + // ASSERT + // Since this method is not in the debounce list, it should be sent every time. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it('should not debounce any notifications if the option is not provided', async () => { + // ARRANGE + // Use the default protocol from beforeEach, which has no debounce options. + await protocol.connect(transport); + + // ACT + await protocol.notification({ method: 'any/method' }); + await protocol.notification({ method: 'any/method' }); + + // ASSERT + // Without the config, behavior should be immediate sending. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + + it('should handle sequential batches of debounced notifications correctly', async () => { + // ARRANGE + protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] }); + await protocol.connect(transport); + + // ACT (Batch 1) + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + await flushMicrotasks(); + + // ASSERT (Batch 1) + expect(sendSpy).toHaveBeenCalledTimes(1); + + // ACT (Batch 2) + // After the first batch has been sent, a new batch should be possible. + protocol.notification({ method: 'test/debounced' }); + protocol.notification({ method: 'test/debounced' }); + await flushMicrotasks(); + + // ASSERT (Batch 2) + // The total number of sends should now be 2. + expect(sendSpy).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('InMemoryTaskMessageQueue', () => { + let queue: TaskMessageQueue; + const taskId = 'test-task-id'; + + beforeEach(() => { + queue = new InMemoryTaskMessageQueue(); + }); + + describe('enqueue/dequeue maintains FIFO order', () => { + it('should maintain FIFO order for multiple messages', async () => { + const msg1 = { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test1' }, + timestamp: 1 + }; + const msg2 = { + type: 'request' as const, + message: { jsonrpc: '2.0' as const, id: 1, method: 'test2' }, + timestamp: 2 + }; + const msg3 = { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test3' }, + timestamp: 3 + }; + + await queue.enqueue(taskId, msg1); + await queue.enqueue(taskId, msg2); + await queue.enqueue(taskId, msg3); + + expect(await queue.dequeue(taskId)).toEqual(msg1); + expect(await queue.dequeue(taskId)).toEqual(msg2); + expect(await queue.dequeue(taskId)).toEqual(msg3); + }); + + it('should return undefined when dequeuing from empty queue', async () => { + expect(await queue.dequeue(taskId)).toBeUndefined(); + }); + }); + + describe('dequeueAll operation', () => { + it('should return all messages in FIFO order', async () => { + const msg1 = { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test1' }, + timestamp: 1 + }; + const msg2 = { + type: 'request' as const, + message: { jsonrpc: '2.0' as const, id: 1, method: 'test2' }, + timestamp: 2 + }; + const msg3 = { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test3' }, + timestamp: 3 + }; + + await queue.enqueue(taskId, msg1); + await queue.enqueue(taskId, msg2); + await queue.enqueue(taskId, msg3); + + const allMessages = await queue.dequeueAll(taskId); + + expect(allMessages).toEqual([msg1, msg2, msg3]); + }); + + it('should return empty array for empty queue', async () => { + const allMessages = await queue.dequeueAll(taskId); + expect(allMessages).toEqual([]); + }); + + it('should clear queue after dequeueAll', async () => { + await queue.enqueue(taskId, { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test1' }, + timestamp: 1 + }); + await queue.enqueue(taskId, { + type: 'notification' as const, + message: { jsonrpc: '2.0' as const, method: 'test2' }, + timestamp: 2 + }); + + await queue.dequeueAll(taskId); + + expect(await queue.dequeue(taskId)).toBeUndefined(); + }); + }); +}); + +describe('mergeCapabilities', () => { + it('should merge client capabilities', () => { + const base: ClientCapabilities = { + sampling: {}, + roots: { + listChanged: true + } + }; + + const additional: ClientCapabilities = { + experimental: { + feature: { + featureFlag: true + } + }, + elicitation: {}, + roots: { + listChanged: true + } + }; + + const merged = mergeCapabilities(base, additional); + expect(merged).toEqual({ + sampling: {}, + elicitation: {}, + roots: { + listChanged: true + }, + experimental: { + feature: { + featureFlag: true + } + } + }); + }); + + it('should merge server capabilities', () => { + const base: ServerCapabilities = { + logging: {}, + prompts: { + listChanged: true + } + }; + + const additional: ServerCapabilities = { + resources: { + subscribe: true + }, + prompts: { + listChanged: true + } + }; + + const merged = mergeCapabilities(base, additional); + expect(merged).toEqual({ + logging: {}, + prompts: { + listChanged: true + }, + resources: { + subscribe: true + } + }); + }); + + it('should override existing values with additional values', () => { + const base: ServerCapabilities = { + prompts: { + listChanged: false + } + }; + + const additional: ServerCapabilities = { + prompts: { + listChanged: true + } + }; + + const merged = mergeCapabilities(base, additional); + expect(merged.prompts!.listChanged).toBe(true); + }); + + it('should handle empty objects', () => { + const base = {}; + const additional = {}; + const merged = mergeCapabilities(base, additional); + expect(merged).toEqual({}); + }); +}); + +describe('Task-based execution', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = createTestProtocol({ taskStore: createMockTaskStore(), taskMessageQueue: new InMemoryTaskMessageQueue() }); + }); + + describe('request with task metadata', () => { + it('should include task parameters at top level', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 30000, + pollInterval: 1000 + } + }).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'tools/call', + params: { + name: 'test-tool', + task: { + ttl: 30000, + pollInterval: 1000 + } + } + }), + expect.any(Object) + ); + }); + + it('should preserve existing _meta and add task parameters at top level', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { + name: 'test-tool', + _meta: { + customField: 'customValue' + } + } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 60000 + } + }).catch(() => { + // May not complete, ignore error + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + name: 'test-tool', + _meta: { + customField: 'customValue' + }, + task: { + ttl: 60000 + } + } + }), + expect.any(Object) + ); + }); + + it('should return Promise for task-augmented request', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + const resultPromise = testRequest(protocol, request, resultSchema, { + task: { + ttl: 30000 + } + }); + + expect(resultPromise).toBeDefined(); + expect(resultPromise).toBeInstanceOf(Promise); + }); + }); + + describe('relatedTask metadata', () => { + it('should inject relatedTask metadata into _meta field', async () => { + await protocol.connect(transport); + + const request = { + method: 'notifications/message', + params: { data: 'test' } + }; + + const resultSchema = z.object({}); + + // Start the request (don't await completion, just let it send) + void testRequest(protocol, request, resultSchema, { + relatedTask: { + taskId: 'parent-task-123' + } + }).catch(() => { + // May not complete, ignore error + }); + + // Wait a bit for the request to be queued + await new Promise(resolve => setTimeout(resolve, 10)); + + // Requests with relatedTask should be queued, not sent via transport + // This prevents duplicate delivery for bidirectional transports + expect(sendSpy).not.toHaveBeenCalled(); + + // Verify the message was queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + }); + + it('should work with notification method', async () => { + await protocol.connect(transport); + + await protocol.notification( + { + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }, + { + relatedTask: { + taskId: 'parent-task-456' + } + } + ); + + // Notifications with relatedTask should be queued, not sent via transport + // This prevents duplicate delivery for bidirectional transports + expect(sendSpy).not.toHaveBeenCalled(); + + // Verify the message was queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue('parent-task-456'); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.method).toBe('notifications/message'); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: 'parent-task-456' }); + }); + }); + + describe('task metadata combination', () => { + it('should combine task, relatedTask, and progress metadata', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + // Start the request (don't await completion, just let it send) + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 60000, + pollInterval: 1000 + }, + relatedTask: { + taskId: 'parent-task' + }, + onprogress: vi.fn() + }).catch(() => { + // May not complete, ignore error + }); + + // Wait a bit for the request to be queued + await new Promise(resolve => setTimeout(resolve, 10)); + + // Requests with relatedTask should be queued, not sent via transport + // This prevents duplicate delivery for bidirectional transports + expect(sendSpy).not.toHaveBeenCalled(); + + // Verify the message was queued with all metadata combined + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue('parent-task'); + assertQueuedRequest(queuedMessage); + expect(queuedMessage.message.params).toMatchObject({ + name: 'test-tool', + task: { + ttl: 60000, + pollInterval: 1000 + }, + _meta: { + [RELATED_TASK_META_KEY]: { + taskId: 'parent-task' + }, + progressToken: expect.any(Number) + } + }); + }); + }); + + describe('task status transitions', () => { + it('should not auto-update task status when a task-augmented request completes', async () => { + const mockTaskStore = createMockTaskStore(); + const localProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const localTransport = new MockTransport(); + await localProtocol.connect(localTransport); + + localProtocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'done' }] }; + }); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { + name: 'test-tool', + arguments: {}, + task: { ttl: 60000, pollInterval: 1000 } + } + }); + + // Allow the request to be processed + await new Promise(resolve => setTimeout(resolve, 20)); + + // The protocol layer must not call updateTaskStatus — that is solely the tool implementor's responsibility + expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); + }); + + it('should handle requests with task creation parameters in top-level task field', async () => { + // This test documents that task creation parameters are now in the top-level task field + // rather than in _meta, and that task management is handled by tool implementors + const mockTaskStore = createMockTaskStore(); + + protocol = createTestProtocol({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + protocol.setRequestHandler('tools/call', async request => { + // Tool implementor can access task creation parameters from request.params.task + expect(request.params.task).toEqual({ + ttl: 60000, + pollInterval: 1000 + }); + return { content: [{ type: 'text', text: 'success' }] }; + }); + + transport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test', + arguments: {}, + task: { + ttl: 60000, + pollInterval: 1000 + } + } + }); + + // Wait for the request to be processed + await new Promise(resolve => setTimeout(resolve, 10)); + }); + }); + + describe('assertTaskHandlerCapability', () => { + it('should invoke assertTaskHandlerCapability when an inbound task-augmented request arrives', async () => { + const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); + const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); + const localTransport = new MockTransport(); + await localProtocol.connect(localTransport); + + localProtocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'ok' }] }; + }); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: {}, + task: { ttl: 30000, pollInterval: 500 } + } + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(spy).toHaveBeenCalledOnce(); + expect(spy).toHaveBeenCalledWith('tools/call'); + }); + + it('should not invoke assertTaskHandlerCapability for non-task-augmented requests', async () => { + const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); + const spy = vi.spyOn(localProtocol, 'assertTaskHandlerCapability' as never); + const localTransport = new MockTransport(); + await localProtocol.connect(localTransport); + + localProtocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'ok' }] }; + }); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'my-tool', arguments: {} } + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should succeed with default no-op assertTaskHandlerCapability', async () => { + const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); + const localTransport = new MockTransport(); + const localSendSpy = vi.spyOn(localTransport, 'send'); + await localProtocol.connect(localTransport); + + localProtocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'ok' }] }; + }); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: {}, + task: { ttl: 30000, pollInterval: 500 } + } + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + // The response should be a success, not an error + expect(localSendSpy).toHaveBeenCalledOnce(); + const response = localSendSpy.mock.calls[0]![0] as { error?: unknown }; + expect(response.error).toBeUndefined(); + }); + + it('should send a JSON-RPC error response when assertTaskHandlerCapability throws', async () => { + const localProtocol = createTestProtocol({ taskStore: createMockTaskStore() }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(localProtocol as any, 'assertTaskHandlerCapability').mockImplementation(() => { + throw new Error('Task handler capability not declared'); + }); + const localTransport = new MockTransport(); + const sendSpy = vi.spyOn(localTransport, 'send'); + await localProtocol.connect(localTransport); + + localProtocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'ok' }] }; + }); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'my-tool', + arguments: {}, + task: { ttl: 30000, pollInterval: 500 } + } + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + + // Verify the error was sent back as a JSON-RPC error response (matching main's behavior) + expect(sendSpy).toHaveBeenCalledOnce(); + const response = sendSpy.mock.calls[0]![0] as { error?: { message?: string } }; + expect(response.error).toBeDefined(); + expect(response.error!.message).toBe('Task handler capability not declared'); + }); + }); + + describe('pollInterval fallback in _waitForTaskUpdate', () => { + it('should fall back to defaultTaskPollInterval when task has no pollInterval', async () => { + const mockTaskStore = createMockTaskStore(); + + const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { + method: 'test/method', + params: {} + }); + // Override pollInterval to be undefined on the stored task + const storedTask = await mockTaskStore.getTask(task.taskId); + if (storedTask) { + storedTask.pollInterval = undefined as unknown as number; + } + + const localProtocol = createTestProtocol({ + taskStore: mockTaskStore, + defaultTaskPollInterval: 100 + }); + const localTransport = new MockTransport(); + const sendSpy = vi.spyOn(localTransport, 'send'); + await localProtocol.connect(localTransport); + + // Send tasks/result request — task is non-terminal so it will poll + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/result', + params: { taskId: task.taskId } + }); + + // Use a macrotask to complete the task AFTER the handler has entered polling + setTimeout(() => { + mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); + }, 10); + + // At 50ms the 100ms poll hasn't fired yet + await new Promise(resolve => setTimeout(resolve, 50)); + expect(sendSpy).not.toHaveBeenCalled(); + + // At 200ms the poll should have fired and found the completed task + await new Promise(resolve => setTimeout(resolve, 150)); + expect(sendSpy).toHaveBeenCalled(); + }); + + it('should fall back to 1000ms when both pollInterval and defaultTaskPollInterval are absent', async () => { + const mockTaskStore = createMockTaskStore(); + + const task = await mockTaskStore.createTask({ pollInterval: undefined as unknown as number }, 1, { + method: 'test/method', + params: {} + }); + const storedTask = await mockTaskStore.getTask(task.taskId); + if (storedTask) { + storedTask.pollInterval = undefined as unknown as number; + } + + // No defaultTaskPollInterval — should fall back to 1000ms + const localProtocol = createTestProtocol({ + taskStore: mockTaskStore + }); + const localTransport = new MockTransport(); + const sendSpy = vi.spyOn(localTransport, 'send'); + await localProtocol.connect(localTransport); + + localTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/result', + params: { taskId: task.taskId } + }); + + // Complete the task via macrotask so the handler enters polling first + setTimeout(() => { + mockTaskStore.storeTaskResult(task.taskId, 'completed', { content: [{ type: 'text', text: 'done' }] }); + }, 10); + + // At 500ms the 1000ms poll hasn't fired yet + await new Promise(resolve => setTimeout(resolve, 500)); + expect(sendSpy).not.toHaveBeenCalled(); + + // At 1100ms the poll should have fired + await new Promise(resolve => setTimeout(resolve, 600)); + expect(sendSpy).toHaveBeenCalled(); + }); + }); + + describe('listTasks', () => { + it('should handle tasks/list requests and return tasks from TaskStore', async () => { + const listedTasks = createLatch(); + const mockTaskStore = createMockTaskStore({ + onList: () => listedTasks.releaseLatch() + }); + const task1 = await mockTaskStore.createTask( + { + pollInterval: 500 + }, + 1, + { + method: 'test/method', + params: {} + } + ); + // Manually set status to completed for this test + await mockTaskStore.updateTaskStatus(task1.taskId, 'completed'); + + const task2 = await mockTaskStore.createTask( + { + ttl: 60000, + pollInterval: 1000 + }, + 2, + { + method: 'test/method', + params: {} + } + ); + + protocol = createTestProtocol({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + // Simulate receiving a tasks/list request + transport.onmessage?.({ + jsonrpc: '2.0', + id: 3, + method: 'tasks/list', + params: {} + }); + + await listedTasks.waitForLatch(); + + expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); + const sentMessage = sendSpy.mock.calls[0]![0]; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(3); + expect(sentMessage.result.tasks).toEqual([ + { + taskId: task1.taskId, + status: 'completed', + ttl: null, + createdAt: expect.any(String), + lastUpdatedAt: expect.any(String), + pollInterval: 500 + }, + { + taskId: task2.taskId, + status: 'working', + ttl: 60000, + createdAt: expect.any(String), + lastUpdatedAt: expect.any(String), + pollInterval: 1000 + } + ]); + expect(sentMessage.result._meta).toEqual({}); + }); + + it('should handle tasks/list requests with cursor for pagination', async () => { + const listedTasks = createLatch(); + const mockTaskStore = createMockTaskStore({ + onList: () => listedTasks.releaseLatch() + }); + const task3 = await mockTaskStore.createTask( + { + pollInterval: 500 + }, + 1, + { + method: 'test/method', + params: {} + } + ); + + protocol = createTestProtocol({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + // Simulate receiving a tasks/list request with cursor + transport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/list', + params: { + cursor: 'task-2' + } + }); + + await listedTasks.waitForLatch(); + + expect(mockTaskStore.listTasks).toHaveBeenCalledWith('task-2', undefined); + const sentMessage = sendSpy.mock.calls[0]![0]; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(2); + expect(sentMessage.result.tasks).toEqual([ + { + taskId: task3.taskId, + status: 'working', + ttl: null, + createdAt: expect.any(String), + lastUpdatedAt: expect.any(String), + pollInterval: 500 + } + ]); + expect(sentMessage.result.nextCursor).toBeUndefined(); + expect(sentMessage.result._meta).toEqual({}); + }); + + it('should handle tasks/list requests with empty results', async () => { + const listedTasks = createLatch(); + const mockTaskStore = createMockTaskStore({ + onList: () => listedTasks.releaseLatch() + }); + + protocol = createTestProtocol({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + // Simulate receiving a tasks/list request + transport.onmessage?.({ + jsonrpc: '2.0', + id: 3, + method: 'tasks/list', + params: {} + }); + + await listedTasks.waitForLatch(); + + expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); + const sentMessage = sendSpy.mock.calls[0]![0]; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(3); + expect(sentMessage.result.tasks).toEqual([]); + expect(sentMessage.result.nextCursor).toBeUndefined(); + expect(sentMessage.result._meta).toEqual({}); + }); + + it('should return error for invalid cursor', async () => { + const mockTaskStore = createMockTaskStore(); + mockTaskStore.listTasks.mockRejectedValue(new Error('Invalid cursor: bad-cursor')); + + protocol = createTestProtocol({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + // Simulate receiving a tasks/list request with invalid cursor + transport.onmessage?.({ + jsonrpc: '2.0', + id: 4, + method: 'tasks/list', + params: { + cursor: 'bad-cursor' + } + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockTaskStore.listTasks).toHaveBeenCalledWith('bad-cursor', undefined); + const sentMessage = sendSpy.mock.calls[0]![0]; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(4); + expect(sentMessage.error).toBeDefined(); + expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code + expect(sentMessage.error.message).toContain('Failed to list tasks'); + expect(sentMessage.error.message).toContain('Invalid cursor'); + }); + + it('should call listTasks method from client side', async () => { + await protocol.connect(transport); + + const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks(); + + // Simulate server response + setTimeout(() => { + transport.onmessage?.({ + jsonrpc: '2.0', + id: sendSpy.mock.calls[0]![0].id, + result: { + tasks: [ + { + taskId: 'task-1', + status: 'completed', + ttl: null, + createdAt: '2024-01-01T00:00:00Z', + lastUpdatedAt: '2024-01-01T00:00:00Z', + pollInterval: 500 + } + ], + nextCursor: undefined, + _meta: {} + } + }); + }, 10); + + const result = await listTasksPromise; + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'tasks/list', + params: undefined + }), + expect.any(Object) + ); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.taskId).toBe('task-1'); + }); + + it('should call listTasks with cursor from client side', async () => { + await protocol.connect(transport); + + const listTasksPromise = (protocol as unknown as TestProtocolInternals)._taskManager.listTasks({ cursor: 'task-10' }); + + // Simulate server response + setTimeout(() => { + transport.onmessage?.({ + jsonrpc: '2.0', + id: sendSpy.mock.calls[0]![0].id, + result: { + tasks: [ + { + taskId: 'task-11', + status: 'working', + ttl: 30000, + createdAt: '2024-01-01T00:00:00Z', + lastUpdatedAt: '2024-01-01T00:00:00Z', + pollInterval: 1000 + } + ], + nextCursor: 'task-11', + _meta: {} + } + }); + }, 10); + + const result = await listTasksPromise; + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'tasks/list', + params: { + cursor: 'task-10' + } + }), + expect.any(Object) + ); + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0]?.taskId).toBe('task-11'); + expect(result.nextCursor).toBe('task-11'); + }); + }); + + describe('cancelTask', () => { + it('should handle tasks/cancel requests and update task status to cancelled', async () => { + const taskDeleted = createLatch(); + const mockTaskStore = createMockTaskStore(); + const task = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + mockTaskStore.getTask.mockResolvedValue(task); + mockTaskStore.updateTaskStatus.mockImplementation(async (taskId: string, status: string) => { + if (taskId === task.taskId && status === 'cancelled') { + taskDeleted.releaseLatch(); + return; + } + throw new Error('Task not found'); + }); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 5, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + + await taskDeleted.waitForLatch(); + + expect(mockTaskStore.getTask).toHaveBeenCalledWith(task.taskId, undefined); + expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( + task.taskId, + 'cancelled', + 'Client cancelled task execution.', + undefined + ); + const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCResultResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(5); + expect(sentMessage.result._meta).toBeDefined(); + }); + + it('should return error with code -32602 when task does not exist', async () => { + const taskDeleted = createLatch(); + const mockTaskStore = createMockTaskStore(); + + mockTaskStore.getTask.mockResolvedValue(null); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 6, + method: 'tasks/cancel', + params: { + taskId: 'non-existent' + } + }); + + // Wait a bit for the async handler to complete + await new Promise(resolve => setTimeout(resolve, 10)); + taskDeleted.releaseLatch(); + + expect(mockTaskStore.getTask).toHaveBeenCalledWith('non-existent', undefined); + const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(6); + expect(sentMessage.error).toBeDefined(); + expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code + expect(sentMessage.error.message).toContain('Task not found'); + }); + + it('should return error with code -32602 when trying to cancel a task in terminal status', async () => { + const mockTaskStore = createMockTaskStore(); + const completedTask = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + // Set task to completed status + await mockTaskStore.updateTaskStatus(completedTask.taskId, 'completed'); + completedTask.status = 'completed'; + + // Reset the mock so we can check it's not called during cancellation + mockTaskStore.updateTaskStatus.mockClear(); + mockTaskStore.getTask.mockResolvedValue(completedTask); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 7, + method: 'tasks/cancel', + params: { + taskId: completedTask.taskId + } + }); + + // Wait a bit for the async handler to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockTaskStore.getTask).toHaveBeenCalledWith(completedTask.taskId, undefined); + expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); + const sentMessage = sendSpy.mock.calls[0]![0] as unknown as JSONRPCErrorResponse; + expect(sentMessage.jsonrpc).toBe('2.0'); + expect(sentMessage.id).toBe(7); + expect(sentMessage.error).toBeDefined(); + expect(sentMessage.error.code).toBe(-32602); // InvalidParams error code + expect(sentMessage.error.message).toContain('Cannot cancel task in terminal status'); + }); + + it('should call cancelTask method from client side', async () => { + await protocol.connect(transport); + + const deleteTaskPromise = (protocol as unknown as TestProtocolInternals)._taskManager.cancelTask({ taskId: 'task-to-delete' }); + + // Simulate server response - per MCP spec, CancelTaskResult is Result & Task + setTimeout(() => { + transport.onmessage?.({ + jsonrpc: '2.0', + id: sendSpy.mock.calls[0]![0].id, + result: { + _meta: {}, + taskId: 'task-to-delete', + status: 'cancelled', + ttl: 60000, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString() + } + }); + }, 0); + + const result = await deleteTaskPromise; + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'tasks/cancel', + params: { + taskId: 'task-to-delete' + } + }), + expect.any(Object) + ); + expect(result._meta).toBeDefined(); + expect(result.taskId).toBe('task-to-delete'); + expect(result.status).toBe('cancelled'); + }); + }); + + describe('task status notifications', () => { + it('should call getTask after updateTaskStatus to enable notification sending', async () => { + const mockTaskStore = createMockTaskStore(); + + // Create a task first + const task = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + + await serverProtocol.connect(serverTransport); + + // Simulate cancelling the task + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify that updateTaskStatus was called + expect(mockTaskStore.updateTaskStatus).toHaveBeenCalledWith( + task.taskId, + 'cancelled', + 'Client cancelled task execution.', + undefined + ); + + // Verify that getTask was called after updateTaskStatus + // This is done by the RequestTaskStore wrapper to get the updated task for the notification + const getTaskCalls = mockTaskStore.getTask.mock.calls; + const lastGetTaskCall = getTaskCalls[getTaskCalls.length - 1]; + expect(lastGetTaskCall?.[0]).toBe(task.taskId); + }); + }); + + describe('task metadata handling', () => { + it('should NOT include related-task metadata in tasks/get response', async () => { + const mockTaskStore = createMockTaskStore(); + + // Create a task first + const task = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + // Request task status + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/get', + params: { + taskId: task.taskId + } + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify response does NOT include related-task metadata + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + taskId: task.taskId, + status: 'working' + }) + }) + ); + + // Verify _meta is not present or doesn't contain RELATED_TASK_META_KEY + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; + expect(response.result?._meta?.[RELATED_TASK_META_KEY]).toBeUndefined(); + }); + + it('should NOT include related-task metadata in tasks/list response', async () => { + const mockTaskStore = createMockTaskStore(); + + // Create a task first + await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + // Request task list + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/list', + params: {} + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify response does NOT include related-task metadata + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; + expect(response.result?._meta).toEqual({}); + }); + + it('should NOT include related-task metadata in tasks/cancel response', async () => { + const mockTaskStore = createMockTaskStore(); + + // Create a task first + const task = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + // Cancel the task + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify response does NOT include related-task metadata + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; + expect(response.result?._meta).toEqual({}); + }); + + it('should include related-task metadata in tasks/result response', async () => { + const mockTaskStore = createMockTaskStore(); + + // Create a task and complete it + const task = await mockTaskStore.createTask({}, 1, { + method: 'test/method', + params: {} + }); + + const testResult = { + content: [{ type: 'text', text: 'test result' }] + }; + + await mockTaskStore.storeTaskResult(task.taskId, 'completed', testResult); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore }); + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + // Request task result + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/result', + params: { + taskId: task.taskId + } + }); + + // Wait for async processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify response DOES include related-task metadata + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + result: expect.objectContaining({ + content: testResult.content, + _meta: expect.objectContaining({ + [RELATED_TASK_META_KEY]: { + taskId: task.taskId + } + }) + }) + }) + ); + }); + + it('should propagate related-task metadata to handler sendRequest and sendNotification', async () => { + const mockTaskStore = createMockTaskStore(); + + const serverProtocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + const serverTransport = new MockTransport(); + const sendSpy = vi.spyOn(serverTransport, 'send'); + + await serverProtocol.connect(serverTransport); + + // Set up a handler that uses sendRequest and sendNotification + serverProtocol.setRequestHandler('tools/call', async (_request, ctx) => { + // Send a notification using the ctx.mcpReq.notify + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'info', data: 'test' } + }); + + return { + content: [{ type: 'text', text: 'done' }] + }; + }); + + // Send a request with related-task metadata + let handlerPromise: Promise | undefined; + const originalOnMessage = serverTransport.onmessage; + + serverTransport.onmessage = message => { + handlerPromise = Promise.resolve(originalOnMessage?.(message)); + return handlerPromise; + }; + + serverTransport.onmessage({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'test-tool', + _meta: { + [RELATED_TASK_META_KEY]: { + taskId: 'parent-task-123' + } + } + } + }); + + // Wait for handler to complete + if (handlerPromise) { + await handlerPromise; + } + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify the notification was QUEUED (not sent via transport) + // Messages with relatedTask metadata should be queued for delivery via tasks/result + // to prevent duplicate delivery for bidirectional transports + const queue = (serverProtocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue('parent-task-123'); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.method).toBe('notifications/message'); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ + taskId: 'parent-task-123' + }); + + // Verify the notification was NOT sent via transport (should be queued instead) + const notificationCalls = sendSpy.mock.calls.filter(call => 'method' in call[0] && call[0].method === 'notifications/message'); + expect(notificationCalls).toHaveLength(0); + }); + }); +}); + +describe('Request Cancellation vs Task Cancellation', () => { + let protocol: Protocol; + let transport: MockTransport; + let taskStore: TaskStore; + + beforeEach(() => { + transport = new MockTransport(); + taskStore = createMockTaskStore(); + protocol = createTestProtocol({ taskStore }); + }); + + describe('notifications/cancelled behavior', () => { + test('should abort request handler when notifications/cancelled is received', async () => { + await protocol.connect(transport); + + // Set up a request handler that checks if it was aborted + let wasAborted = false; + protocol.setRequestHandler('ping', async (_request, ctx) => { + // Simulate a long-running operation + await new Promise(resolve => setTimeout(resolve, 100)); + wasAborted = ctx.mcpReq.signal.aborted; + return {}; + }); + + // Simulate an incoming request + const requestId = 123; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: requestId, + method: 'ping', + params: {} + }); + } + + // Wait a bit for the handler to start + await new Promise(resolve => setTimeout(resolve, 10)); + + // Send cancellation notification + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: requestId, + reason: 'User cancelled' + } + }); + } + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 150)); + + // Verify the request was aborted + expect(wasAborted).toBe(true); + }); + + test('should NOT automatically cancel associated tasks when notifications/cancelled is received', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'test/method', + params: {} + }); + + // Send cancellation notification for the request + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'req-1', + reason: 'User cancelled' + } + }); + } + + // Wait a bit + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify the task status was NOT changed to cancelled + const updatedTask = await taskStore.getTask(task.taskId); + expect(updatedTask?.status).toBe('working'); + expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'cancelled', expect.any(String)); + }); + }); + + describe('tasks/cancel behavior', () => { + test('should cancel task independently of request cancellation', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'test/method', + params: {} + }); + + // Cancel the task using tasks/cancel + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 999, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + } + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify the task was cancelled + expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( + task.taskId, + 'cancelled', + 'Client cancelled task execution.', + undefined + ); + }); + + test('should reject cancellation of terminal tasks', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + // Create a task and mark it as completed + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'test/method', + params: {} + }); + await taskStore.updateTaskStatus(task.taskId, 'completed'); + + // Try to cancel the completed task + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 999, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + } + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify an error was sent + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: 999, + error: expect.objectContaining({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('Cannot cancel task in terminal status') + }) + }) + ); + }); + + test('should return error when task not found', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + // Try to cancel a non-existent task + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 999, + method: 'tasks/cancel', + params: { + taskId: 'non-existent-task' + } + }); + } + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify an error was sent + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: 999, + error: expect.objectContaining({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('Task not found') + }) + }) + ); + }); + }); + + describe('separation of concerns', () => { + test('should allow request cancellation without affecting task', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'test/method', + params: {} + }); + + // Cancel the request (not the task) + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 'req-1', + reason: 'User cancelled request' + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify task is still working + const updatedTask = await taskStore.getTask(task.taskId); + expect(updatedTask?.status).toBe('working'); + }); + + test('should allow task cancellation without affecting request', async () => { + await protocol.connect(transport); + + // Set up a request handler + let requestCompleted = false; + protocol.setRequestHandler('ping', async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + requestCompleted = true; + return {}; + }); + + // Create a task (simulating a long-running tools/call) + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'tools/call', + params: { name: 'long-running-tool', arguments: {} } + }); + + // Start an unrelated ping request + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 123, + method: 'ping', + params: {} + }); + } + + // Cancel the task (not the request) + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 999, + method: 'tasks/cancel', + params: { + taskId: task.taskId + } + }); + } + + // Wait for request to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify request completed normally + expect(requestCompleted).toBe(true); + + // Verify task was cancelled + expect(taskStore.updateTaskStatus).toHaveBeenCalledWith( + task.taskId, + 'cancelled', + 'Client cancelled task execution.', + undefined + ); + }); + }); +}); + +describe('Progress notification support for tasks', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = createTestProtocol({ taskStore: createMockTaskStore() }); + }); + + it('should maintain progress token association after CreateTaskResult is returned', async () => { + const taskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore }); + + const transport = new MockTransport(); + const sendSpy = vi.spyOn(transport, 'send'); + await protocol.connect(transport); + + const progressCallback = vi.fn(); + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + // Start a task-augmented request with progress callback + void testRequest(protocol, request, resultSchema, { + task: { ttl: 60000 }, + onprogress: progressCallback + }).catch(() => { + // May not complete, ignore error + }); + + // Wait a bit for the request to be sent + await new Promise(resolve => setTimeout(resolve, 10)); + + // Get the message ID from the sent request + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; + const messageId = sentRequest.id; + const progressToken = sentRequest.params._meta.progressToken; + + expect(progressToken).toBe(messageId); + + // Simulate CreateTaskResult response + const taskId = 'test-task-123'; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: messageId, + result: { + task: { + taskId, + status: 'working', + ttl: 60000, + createdAt: new Date().toISOString() + } + } + }); + } + + // Wait for response to be processed + await Promise.resolve(); + await Promise.resolve(); + + // Send a progress notification - should still work after CreateTaskResult + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100 + } + }); + } + + // Wait for notification to be processed + await Promise.resolve(); + + // Verify progress callback was invoked + expect(progressCallback).toHaveBeenCalledWith({ + progress: 50, + total: 100 + }); + }); + + it('should stop progress notifications when task reaches terminal status (completed)', async () => { + const taskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore }); + + const transport = new MockTransport(); + const sendSpy = vi.spyOn(transport, 'send'); + await protocol.connect(transport); + + // Set up a request handler that will complete the task + protocol.setRequestHandler('tools/call', async (_request, ctx) => { + if (ctx.task?.store) { + const task = await ctx.task.store.createTask({ ttl: 60000 }); + + // Simulate async work then complete the task + const taskStore = ctx.task.store; + setTimeout(async () => { + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: 'Done' }] + }); + }, 50); + + return { task }; + } + return { content: [] }; + }); + + const progressCallback = vi.fn(); + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + // Start a task-augmented request with progress callback + void testRequest(protocol, request, resultSchema, { + task: { ttl: 60000 }, + onprogress: progressCallback + }).catch(() => { + // May not complete, ignore error + }); + + // Wait a bit for the request to be sent + await new Promise(resolve => setTimeout(resolve, 10)); + + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; + const messageId = sentRequest.id; + const progressToken = sentRequest.params._meta.progressToken; + + // Create a task in the mock store first so it exists when we try to get it later + const createdTask = await taskStore.createTask({ ttl: 60000 }, messageId, request); + const taskId = createdTask.taskId; + + // Simulate CreateTaskResult response + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: messageId, + result: { + task: createdTask + } + }); + } + + await Promise.resolve(); + await Promise.resolve(); + + // Progress notification should work while task is working + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100 + } + }); + } + + await Promise.resolve(); + + expect(progressCallback).toHaveBeenCalledTimes(1); + + // Verify the task-progress association was created + const taskProgressTokens = (protocol as unknown as TestProtocolInternals)._taskManager._taskProgressTokens as Map; + expect(taskProgressTokens.has(taskId)).toBe(true); + expect(taskProgressTokens.get(taskId)).toBe(progressToken); + + // Simulate task completion by triggering an inbound request whose handler + // calls storeTaskResult through the task context (the public RequestTaskStore API). + // This is equivalent to how a real server handler would complete a task. + protocol.setRequestHandler('ping', async (_request, ctx) => { + if (ctx.task?.store) { + await ctx.task.store.storeTaskResult(taskId, 'completed', { content: [] }); + } + return {}; + }); + if (transport.onmessage) { + transport.onmessage({ jsonrpc: '2.0', id: 999, method: 'ping', params: {} }); + } + + // Wait for all async operations including notification sending to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the association was cleaned up + expect(taskProgressTokens.has(taskId)).toBe(false); + + // Try to send progress notification after task completion - should be ignored + progressCallback.mockClear(); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 100, + total: 100 + } + }); + } + + await Promise.resolve(); + + // Progress callback should NOT be invoked after task completion + expect(progressCallback).not.toHaveBeenCalled(); + }); + + it('should stop progress notifications when task reaches terminal status (failed)', async () => { + const taskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore }); + + const transport = new MockTransport(); + const sendSpy = vi.spyOn(transport, 'send'); + await protocol.connect(transport); + + const progressCallback = vi.fn(); + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + void testRequest(protocol, request, resultSchema, { + task: { ttl: 60000 }, + onprogress: progressCallback + }); + + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; + const messageId = sentRequest.id; + const progressToken = sentRequest.params._meta.progressToken; + + // Simulate CreateTaskResult response + const taskId = 'test-task-456'; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: messageId, + result: { + task: { + taskId, + status: 'working', + ttl: 60000, + createdAt: new Date().toISOString() + } + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Simulate task failure via storeTaskResult + await taskStore.storeTaskResult(taskId, 'failed', { + content: [], + isError: true + }); + + // Manually trigger the status notification + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/tasks/status', + params: { + taskId, + status: 'failed', + ttl: 60000, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + statusMessage: 'Task failed' + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Try to send progress notification after task failure - should be ignored + progressCallback.mockClear(); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 75, + total: 100 + } + }); + } + + expect(progressCallback).not.toHaveBeenCalled(); + }); + + it('should stop progress notifications when task is cancelled', async () => { + const taskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore }); + + const transport = new MockTransport(); + const sendSpy = vi.spyOn(transport, 'send'); + await protocol.connect(transport); + + const progressCallback = vi.fn(); + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + void testRequest(protocol, request, resultSchema, { + task: { ttl: 60000 }, + onprogress: progressCallback + }); + + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; + const messageId = sentRequest.id; + const progressToken = sentRequest.params._meta.progressToken; + + // Simulate CreateTaskResult response + const taskId = 'test-task-789'; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: messageId, + result: { + task: { + taskId, + status: 'working', + ttl: 60000, + createdAt: new Date().toISOString() + } + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Simulate task cancellation via updateTaskStatus + await taskStore.updateTaskStatus(taskId, 'cancelled', 'User cancelled'); + + // Manually trigger the status notification + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/tasks/status', + params: { + taskId, + status: 'cancelled', + ttl: 60000, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + statusMessage: 'User cancelled' + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Try to send progress notification after cancellation - should be ignored + progressCallback.mockClear(); + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 25, + total: 100 + } + }); + } + + expect(progressCallback).not.toHaveBeenCalled(); + }); + + it('should use the same progressToken throughout task lifetime', async () => { + const taskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore }); + + const transport = new MockTransport(); + const sendSpy = vi.spyOn(transport, 'send'); + await protocol.connect(transport); + + const progressCallback = vi.fn(); + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + void testRequest(protocol, request, resultSchema, { + task: { ttl: 60000 }, + onprogress: progressCallback + }); + + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; + const messageId = sentRequest.id; + const progressToken = sentRequest.params._meta.progressToken; + + // Simulate CreateTaskResult response + const taskId = 'test-task-consistency'; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: messageId, + result: { + task: { + taskId, + status: 'working', + ttl: 60000, + createdAt: new Date().toISOString() + } + } + }); + } + + await Promise.resolve(); + await Promise.resolve(); + + // Send multiple progress notifications with the same token + const progressUpdates = [ + { progress: 25, total: 100 }, + { progress: 50, total: 100 }, + { progress: 75, total: 100 } + ]; + + for (const update of progressUpdates) { + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, // Same token for all notifications + ...update + } + }); + } + await Promise.resolve(); + } + + // Verify all progress notifications were received with the same token + expect(progressCallback).toHaveBeenCalledTimes(3); + expect(progressCallback).toHaveBeenNthCalledWith(1, { progress: 25, total: 100 }); + expect(progressCallback).toHaveBeenNthCalledWith(2, { progress: 50, total: 100 }); + expect(progressCallback).toHaveBeenNthCalledWith(3, { progress: 75, total: 100 }); + }); + + it('should maintain progressToken throughout task lifetime', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'long-running-tool' } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + const onProgressMock = vi.fn(); + + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 60000 + }, + onprogress: onProgressMock + }); + + const sentMessage = sendSpy.mock.calls[0]![0]; + expect(sentMessage.params._meta.progressToken).toBeDefined(); + }); + + it('should support progress notifications with task-augmented requests', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + content: z.array(z.object({ type: z.literal('text'), text: z.string() })) + }); + + const onProgressMock = vi.fn(); + + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 30000 + }, + onprogress: onProgressMock + }); + + const sentMessage = sendSpy.mock.calls[0]![0]; + const progressToken = sentMessage.params._meta.progressToken; + + // Simulate progress notification + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100, + message: 'Processing...' + } + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 50, + total: 100, + message: 'Processing...' + }); + }); + + it('should continue progress notifications after CreateTaskResult', async () => { + await protocol.connect(transport); + + const request = { + method: 'tools/call', + params: { name: 'test-tool' } + }; + + const resultSchema = z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.number().nullable(), + createdAt: z.string() + }) + }); + + const onProgressMock = vi.fn(); + + void testRequest(protocol, request, resultSchema, { + task: { + ttl: 30000 + }, + onprogress: onProgressMock + }); + + const sentMessage = sendSpy.mock.calls[0]![0]; + const progressToken = sentMessage.params._meta.progressToken; + + // Simulate CreateTaskResult response + setTimeout(() => { + transport.onmessage?.({ + jsonrpc: '2.0', + id: sentMessage.id, + result: { + task: { + taskId: 'task-123', + status: 'working', + ttl: 30000, + createdAt: new Date().toISOString() + } + } + }); + }, 5); + + // Progress notifications should still work + setTimeout(() => { + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { + progressToken, + progress: 75, + total: 100 + } + }); + }, 10); + + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(onProgressMock).toHaveBeenCalledWith({ + progress: 75, + total: 100 + }); + }); +}); + +describe('Capability negotiation for tasks', () => { + it('should use empty objects for capability fields', () => { + const serverCapabilities = { + tasks: { + list: {}, + cancel: {}, + requests: { + tools: { + call: {} + } + } + } + }; + + expect(serverCapabilities.tasks.list).toEqual({}); + expect(serverCapabilities.tasks.cancel).toEqual({}); + expect(serverCapabilities.tasks.requests.tools.call).toEqual({}); + }); + + it('should include list and cancel in server capabilities', () => { + const serverCapabilities = { + tasks: { + list: {}, + cancel: {} + } + }; + + expect('list' in serverCapabilities.tasks).toBe(true); + expect('cancel' in serverCapabilities.tasks).toBe(true); + }); + + it('should include list and cancel in client capabilities', () => { + const clientCapabilities = { + tasks: { + list: {}, + cancel: {} + } + }; + + expect('list' in clientCapabilities.tasks).toBe(true); + expect('cancel' in clientCapabilities.tasks).toBe(true); + }); +}); + +describe('Message interception for task-related notifications', () => { + it('should queue notifications with io.modelcontextprotocol/related-task metadata', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Create a task first + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send a notification with related task metadata + await server.notification( + { + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }, + { + relatedTask: { taskId: task.taskId } + } + ); + + // Access the private queue to verify the message was queued + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue(task.taskId); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.method).toBe('notifications/message'); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); + }); + + it('should not queue notifications without related-task metadata', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Send a notification without related task metadata + await server.notification({ + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }); + + // Verify message was not queued (notification without metadata goes through transport) + // We can't directly check the queue, but we know it wasn't queued because + // notifications without relatedTask metadata are sent via transport, not queued + }); + + // Test removed: _taskResultWaiters was removed in favor of polling-based task updates + // The functionality is still tested through integration tests that verify message queuing works + + it('should propagate queue overflow errors without failing the task', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Fill the queue to max capacity (100 messages) + for (let i = 0; i < 100; i++) { + await server.notification( + { + method: 'notifications/message', + params: { level: 'info', data: `message ${i}` } + }, + { + relatedTask: { taskId: task.taskId } + } + ); + } + + // Try to add one more message - should throw an error + await expect( + server.notification( + { + method: 'notifications/message', + params: { level: 'info', data: 'overflow message' } + }, + { + relatedTask: { taskId: task.taskId } + } + ) + ).rejects.toThrow('overflow'); + + // Verify the task was NOT automatically failed by the Protocol + // (implementations can choose to fail tasks on overflow if they want) + expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); + }); + + it('should extract task ID correctly from metadata', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + const taskId = 'custom-task-id-123'; + + // Send a notification with custom task ID + await server.notification( + { + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }, + { + relatedTask: { taskId } + } + ); + + // Verify the message was queued under the correct task ID + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + const queuedMessage = await queue!.dequeue(taskId); + expect(queuedMessage).toBeDefined(); + }); + + it('should preserve message order when queuing multiple notifications', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send multiple notifications + for (let i = 0; i < 5; i++) { + await server.notification( + { + method: 'notifications/message', + params: { level: 'info', data: `message ${i}` } + }, + { + relatedTask: { taskId: task.taskId } + } + ); + } + + // Verify messages are in FIFO order + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + for (let i = 0; i < 5; i++) { + const queuedMessage = await queue!.dequeue(task.taskId); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.params!.data).toBe(`message ${i}`); + } + }); +}); + +describe('Message interception for task-related requests', () => { + it('should queue requests with io.modelcontextprotocol/related-task metadata', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Create a task first + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send a request with related task metadata (don't await - we're testing queuing) + const requestPromise = testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({}), + { + relatedTask: { taskId: task.taskId } + } + ); + + // Access the private queue to verify the message was queued + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue(task.taskId); + assertQueuedRequest(queuedMessage); + expect(queuedMessage.message.method).toBe('ping'); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ taskId: task.taskId }); + + // Verify resolver is stored in _requestResolvers map (not in the message) + const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; + const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; + expect(resolvers.has(requestId)).toBe(true); + + // Clean up - send a response to prevent hanging promise + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + result: {} + }); + + await requestPromise; + }); + + it('should not queue requests without related-task metadata', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Send a request without related task metadata + const requestPromise = testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({}) + ); + + // Verify queue exists (but we don't track size in the new API) + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Clean up - send a response + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: {} + }); + + await requestPromise; + }); + + // Test removed: _taskResultWaiters was removed in favor of polling-based task updates + // The functionality is still tested through integration tests that verify message queuing works + + it('should store request resolver for response routing', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send a request with related task metadata + const requestPromise = testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({}), + { + relatedTask: { taskId: task.taskId } + } + ); + + // Verify the resolver was stored + const resolvers = (server as unknown as TestProtocolInternals)._taskManager._requestResolvers; + expect(resolvers.size).toBe(1); + + // Get the request ID from the queue + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const queuedMessage = await queue!.dequeue(task.taskId); + const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; + + expect(resolvers.has(requestId)).toBe(true); + + // Send a response to trigger resolver + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + result: {} + }); + + await requestPromise; + + // Verify resolver was cleaned up after response + expect(resolvers.has(requestId)).toBe(false); + }); + + it('should route responses to side-channeled requests', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const queue = new InMemoryTaskMessageQueue(); + const server = createTestProtocol({ taskStore, taskMessageQueue: queue }); + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send a request with related task metadata + const requestPromise = testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({ message: z.string() }), + { + relatedTask: { taskId: task.taskId } + } + ); + + // Get the request ID from the queue + const queuedMessage = await queue.dequeue(task.taskId); + const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; + + // Enqueue a response message to the queue (simulating client sending response back) + await queue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: requestId, + result: { message: 'pong' } + }, + timestamp: Date.now() + }); + + // Simulate a client calling tasks/result which will process the response + // This is done by creating a mock request handler that will trigger the GetTaskPayloadRequest handler + const mockRequestId = 999; + transport.onmessage?.({ + jsonrpc: '2.0', + id: mockRequestId, + method: 'tasks/result', + params: { taskId: task.taskId } + }); + + // Wait for the response to be processed + await new Promise(resolve => setTimeout(resolve, 50)); + + // Mark task as completed + await taskStore.updateTaskStatus(task.taskId, 'completed'); + await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); + + // Verify the response was routed correctly + const result = await requestPromise; + expect(result).toEqual({ message: 'pong' }); + }); + + it('should log error when resolver is missing for side-channeled request', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + const errors: Error[] = []; + server.onerror = (error: Error) => { + errors.push(error); + }; + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Send a request with related task metadata + void testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({ message: z.string() }), + { + relatedTask: { taskId: task.taskId } + } + ); + + // Get the request ID from the queue + const queue = (server as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const queuedMessage = await queue!.dequeue(task.taskId); + const requestId = (queuedMessage!.message as JSONRPCRequest).id as RequestId; + + // Manually delete the resolver to simulate missing resolver + (server as unknown as TestProtocolInternals)._taskManager._requestResolvers.delete(requestId); + + // Enqueue a response message - this should trigger the error logging when processed + await queue!.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: requestId, + result: { message: 'pong' } + }, + timestamp: Date.now() + }); + + // Simulate a client calling tasks/result which will process the response + const mockRequestId = 888; + transport.onmessage?.({ + jsonrpc: '2.0', + id: mockRequestId, + method: 'tasks/result', + params: { taskId: task.taskId } + }); + + // Wait for the response to be processed + await new Promise(resolve => setTimeout(resolve, 50)); + + // Mark task as completed + await taskStore.updateTaskStatus(task.taskId, 'completed'); + await taskStore.storeTaskResult(task.taskId, 'completed', { _meta: {} }); + + // Wait a bit more for error to be logged + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify error was logged + expect(errors.length).toBeGreaterThanOrEqual(1); + expect(errors.some(e => e.message.includes('Response handler missing for request'))).toBe(true); + }); + + it('should propagate queue overflow errors for requests without failing the task', async () => { + const taskStore = createMockTaskStore(); + const transport = new MockTransport(); + const server = createTestProtocol({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue(), maxTaskQueueSize: 100 }); + + await server.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'test-request-1', { method: 'tools/call', params: {} }); + + // Fill the queue to max capacity (100 messages) + const promises: Promise[] = []; + for (let i = 0; i < 100; i++) { + const promise = testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({}), + { + relatedTask: { taskId: task.taskId } + } + ).catch(() => { + // Requests will remain pending until task completes or fails + }); + promises.push(promise); + } + + // Try to add one more request - should throw an error + await expect( + testRequest( + server, + { + method: 'ping', + params: {} + }, + z.object({}), + { + relatedTask: { taskId: task.taskId } + } + ) + ).rejects.toThrow('overflow'); + + // Verify the task was NOT automatically failed by the Protocol + // (implementations can choose to fail tasks on overflow if they want) + expect(taskStore.updateTaskStatus).not.toHaveBeenCalledWith(task.taskId, 'failed', expect.anything(), expect.anything()); + }); +}); + +describe('Message Interception', () => { + let protocol: Protocol; + let transport: MockTransport; + let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; + + beforeEach(() => { + transport = new MockTransport(); + mockTaskStore = createMockTaskStore(); + protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + }); + + describe('messages with relatedTask metadata are queued', () => { + it('should queue notifications with relatedTask metadata', async () => { + await protocol.connect(transport); + + // Send a notification with relatedTask metadata + await protocol.notification( + { + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }, + { + relatedTask: { + taskId: 'task-123' + } + } + ); + + // Access the private _taskMessageQueue to verify the message was queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue('task-123'); + assertQueuedNotification(queuedMessage); + expect(queuedMessage!.message.method).toBe('notifications/message'); + }); + + it('should queue requests with relatedTask metadata', async () => { + await protocol.connect(transport); + + const mockSchema = z.object({ result: z.string() }); + + // Send a request with relatedTask metadata + const requestPromise = testRequest( + protocol, + { + method: 'test/request', + params: { data: 'test' } + }, + mockSchema, + { + relatedTask: { + taskId: 'task-456' + } + } + ); + + // Access the private _taskMessageQueue to verify the message was queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue('task-456'); + assertQueuedRequest(queuedMessage); + expect(queuedMessage.message.method).toBe('test/request'); + + // Verify resolver is stored in _requestResolvers map (not in the message) + const requestId = queuedMessage.message.id as RequestId; + const resolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; + expect(resolvers.has(requestId)).toBe(true); + + // Clean up the pending request + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + result: { result: 'success' } + }); + await requestPromise; + }); + }); + + describe('server queues responses/errors for task-related requests', () => { + it('should queue response when handling a request with relatedTask metadata', async () => { + await protocol.connect(transport); + + // Set up a request handler that returns a result + protocol.setRequestHandler('ping', async () => { + return {}; + }); + + // Simulate an incoming request with relatedTask metadata + const requestId = 456; + const taskId = 'task-response-test'; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'ping', + params: { + _meta: { + 'io.modelcontextprotocol/related-task': { taskId } + } + } + }); + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the response was queued instead of sent directly + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue(taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage!.type).toBe('response'); + if (queuedMessage!.type === 'response') { + expect(queuedMessage!.message.id).toBe(requestId); + expect(queuedMessage!.message.result).toEqual({}); + } + }); + + it('should queue error when handling a request with relatedTask metadata that throws', async () => { + await protocol.connect(transport); + + // Set up a request handler that throws an error + protocol.setRequestHandler('ping', async () => { + throw new ProtocolError(ProtocolErrorCode.InternalError, 'Test error message'); + }); + + // Simulate an incoming request with relatedTask metadata + const requestId = 789; + const taskId = 'task-error-test'; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'ping', + params: { + _meta: { + 'io.modelcontextprotocol/related-task': { taskId } + } + } + }); + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the error was queued instead of sent directly + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue(taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage!.type).toBe('error'); + if (queuedMessage!.type === 'error') { + expect(queuedMessage!.message.id).toBe(requestId); + expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.InternalError); + expect(queuedMessage!.message.error.message).toContain('Test error message'); + } + }); + + it('should queue MethodNotFound error for unknown method with relatedTask metadata', async () => { + await protocol.connect(transport); + + // Simulate an incoming request for unknown method with relatedTask metadata + const requestId = 101; + const taskId = 'task-not-found-test'; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'unknown/method', + params: { + _meta: { + 'io.modelcontextprotocol/related-task': { taskId } + } + } + }); + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the error was queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + const queuedMessage = await queue!.dequeue(taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage!.type).toBe('error'); + if (queuedMessage!.type === 'error') { + expect(queuedMessage!.message.id).toBe(requestId); + expect(queuedMessage!.message.error.code).toBe(ProtocolErrorCode.MethodNotFound); + } + }); + + it('should send response normally when request has no relatedTask metadata', async () => { + await protocol.connect(transport); + const sendSpy = vi.spyOn(transport, 'send'); + + // Set up a request handler + protocol.setRequestHandler('tools/call', async () => { + return { content: [{ type: 'text', text: 'done' }] }; + }); + + // Simulate an incoming request WITHOUT relatedTask metadata + const requestId = 202; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'tools/call', + params: { name: 'test-tool' } + }); + + // Wait for the handler to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the response was sent through transport, not queued + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: requestId, + result: { content: [{ type: 'text', text: 'done' }] } + }) + ); + }); + }); + + describe('messages without metadata bypass the queue', () => { + it('should not queue notifications without relatedTask metadata', async () => { + await protocol.connect(transport); + + // Send a notification without relatedTask metadata + await protocol.notification({ + method: 'notifications/message', + params: { level: 'info', data: 'test message' } + }); + + // Access the private _taskMessageQueue to verify no messages were queued + // Since we can't check if queues exist without messages, we verify that + // attempting to dequeue returns undefined (no messages queued) + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + }); + + it('should not queue requests without relatedTask metadata', async () => { + await protocol.connect(transport); + + const mockSchema = z.object({ result: z.string() }); + const sendSpy = vi.spyOn(transport, 'send'); + + // Send a request without relatedTask metadata + const requestPromise = testRequest( + protocol, + { + method: 'test/request', + params: { data: 'test' } + }, + mockSchema + ); + + // Access the private _taskMessageQueue to verify no messages were queued + // Since we can't check if queues exist without messages, we verify that + // attempting to dequeue returns undefined (no messages queued) + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Clean up the pending request + const requestId = (sendSpy.mock.calls[0]![0] as JSONRPCResultResponse).id; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + result: { result: 'success' } + }); + await requestPromise; + }); + }); + + describe('task ID extraction from metadata', () => { + it('should extract correct task ID from relatedTask metadata for notifications', async () => { + await protocol.connect(transport); + + const taskId = 'extracted-task-789'; + + // Send a notification with relatedTask metadata + await protocol.notification( + { + method: 'notifications/message', + params: { data: 'test' } + }, + { + relatedTask: { + taskId: taskId + } + } + ); + + // Verify the message was queued under the correct task ID + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Verify a message was queued for this task + const queuedMessage = await queue!.dequeue(taskId); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.method).toBe('notifications/message'); + }); + + it('should extract correct task ID from relatedTask metadata for requests', async () => { + await protocol.connect(transport); + + const taskId = 'extracted-task-999'; + const mockSchema = z.object({ result: z.string() }); + + // Send a request with relatedTask metadata + const requestPromise = testRequest( + protocol, + { + method: 'test/request', + params: { data: 'test' } + }, + mockSchema, + { + relatedTask: { + taskId: taskId + } + } + ); + + // Verify the message was queued under the correct task ID + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Clean up the pending request + const queuedMessage = await queue!.dequeue(taskId); + assertQueuedRequest(queuedMessage); + expect(queuedMessage.message.method).toBe('test/request'); + transport.onmessage?.({ + jsonrpc: '2.0', + id: queuedMessage.message.id, + result: { result: 'success' } + }); + await requestPromise; + }); + + it('should handle multiple messages for different task IDs', async () => { + await protocol.connect(transport); + + // Send messages for different tasks + await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-A' } }); + await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-B' } }); + await protocol.notification({ method: 'test3', params: {} }, { relatedTask: { taskId: 'task-A' } }); + + // Verify messages are queued under correct task IDs + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Verify two messages for task-A + const msg1A = await queue!.dequeue('task-A'); + const msg2A = await queue!.dequeue('task-A'); + const msg3A = await queue!.dequeue('task-A'); // Should be undefined + expect(msg1A).toBeDefined(); + expect(msg2A).toBeDefined(); + expect(msg3A).toBeUndefined(); + + // Verify one message for task-B + const msg1B = await queue!.dequeue('task-B'); + const msg2B = await queue!.dequeue('task-B'); // Should be undefined + expect(msg1B).toBeDefined(); + expect(msg2B).toBeUndefined(); + }); + }); + + describe('queue creation on first message', () => { + it('should queue messages for a task', async () => { + await protocol.connect(transport); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Send first message for a task + await protocol.notification({ method: 'test', params: {} }, { relatedTask: { taskId: 'new-task' } }); + + // Verify message was queued + const msg = await queue!.dequeue('new-task'); + assertQueuedNotification(msg); + expect(msg.message.method).toBe('test'); + }); + + it('should queue multiple messages for the same task', async () => { + await protocol.connect(transport); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Send first message + await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); + + // Send second message + await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'reuse-task' } }); + + // Verify both messages were queued in order + const msg1 = await queue!.dequeue('reuse-task'); + const msg2 = await queue!.dequeue('reuse-task'); + assertQueuedNotification(msg1); + expect(msg1.message.method).toBe('test1'); + assertQueuedNotification(msg2); + expect(msg2.message.method).toBe('test2'); + }); + + it('should queue messages for different tasks separately', async () => { + await protocol.connect(transport); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Send messages for different tasks + await protocol.notification({ method: 'test1', params: {} }, { relatedTask: { taskId: 'task-1' } }); + await protocol.notification({ method: 'test2', params: {} }, { relatedTask: { taskId: 'task-2' } }); + + // Verify messages are queued separately + const msg1 = await queue!.dequeue('task-1'); + const msg2 = await queue!.dequeue('task-2'); + assertQueuedNotification(msg1); + expect(msg1?.message.method).toBe('test1'); + assertQueuedNotification(msg2); + expect(msg2?.message.method).toBe('test2'); + }); + }); + + describe('metadata preservation in queued messages', () => { + it('should preserve relatedTask metadata in queued notification', async () => { + await protocol.connect(transport); + + const relatedTask = { taskId: 'task-meta-123' }; + + await protocol.notification( + { + method: 'test/notification', + params: { data: 'test' } + }, + { relatedTask } + ); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const queuedMessage = await queue!.dequeue('task-meta-123'); + + // Verify the metadata is preserved in the queued message + expect(queuedMessage).toBeDefined(); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.params!._meta).toBeDefined(); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); + }); + + it('should preserve relatedTask metadata in queued request', async () => { + await protocol.connect(transport); + + const relatedTask = { taskId: 'task-meta-456' }; + const mockSchema = z.object({ result: z.string() }); + + const requestPromise = testRequest( + protocol, + { + method: 'test/request', + params: { data: 'test' } + }, + mockSchema, + { relatedTask } + ); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const queuedMessage = await queue!.dequeue('task-meta-456'); + + // Verify the metadata is preserved in the queued message + expect(queuedMessage).toBeDefined(); + assertQueuedRequest(queuedMessage); + expect(queuedMessage.message.params!._meta).toBeDefined(); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual(relatedTask); + + // Clean up + transport.onmessage?.({ + jsonrpc: '2.0', + id: (queuedMessage!.message as JSONRPCRequest).id, + result: { result: 'success' } + }); + await requestPromise; + }); + + it('should preserve existing _meta fields when adding relatedTask', async () => { + await protocol.connect(transport); + + await protocol.notification( + { + method: 'test/notification', + params: { + data: 'test', + _meta: { + customField: 'customValue', + anotherField: 123 + } + } + }, + { + relatedTask: { taskId: 'task-preserve-meta' } + } + ); + + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const queuedMessage = await queue!.dequeue('task-preserve-meta'); + + // Verify both existing and new metadata are preserved + expect(queuedMessage).toBeDefined(); + assertQueuedNotification(queuedMessage); + expect(queuedMessage.message.params!._meta!.customField).toBe('customValue'); + expect(queuedMessage.message.params!._meta!.anotherField).toBe(123); + expect(queuedMessage.message.params!._meta![RELATED_TASK_META_KEY]).toEqual({ + taskId: 'task-preserve-meta' + }); + }); + }); +}); + +describe('Queue lifecycle management', () => { + let protocol: Protocol; + let transport: MockTransport; + let mockTaskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; + + beforeEach(() => { + transport = new MockTransport(); + mockTaskStore = createMockTaskStore(); + protocol = createTestProtocol({ taskStore: mockTaskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + }); + + describe('queue cleanup on task completion', () => { + it('should clear queue when task reaches completed status', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue some messages for the task + await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); + await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); + + // Verify messages are queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Verify messages can be dequeued + const msg1 = await queue!.dequeue(taskId); + const msg2 = await queue!.dequeue(taskId); + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); + + // After cleanup, no more messages should be available + const msg3 = await queue!.dequeue(taskId); + expect(msg3).toBeUndefined(); + }); + + it('should clear queue after delivering messages on tasks/result for completed task', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue a message + await protocol.notification({ method: 'test/notification', params: { data: 'test' } }, { relatedTask: { taskId } }); + + // Mark task as completed + const completedTask = { ...task, status: 'completed' as const }; + mockTaskStore.getTask.mockResolvedValue(completedTask); + mockTaskStore.getTaskResult.mockResolvedValue({ content: [{ type: 'text', text: 'done' }] }); + + // Simulate tasks/result request + const resultPromise = new Promise(resolve => { + transport.onmessage?.({ + jsonrpc: '2.0', + id: 100, + method: 'tasks/result', + params: { taskId } + }); + setTimeout(resolve, 50); + }); + + await resultPromise; + + // Verify queue is cleared after delivery (no messages available) + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const msg = await queue!.dequeue(taskId); + expect(msg).toBeUndefined(); + }); + }); + + describe('queue cleanup on task cancellation', () => { + it('should clear queue when task is cancelled', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue some messages + await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); + + // Verify message is queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + const msg1 = await queue!.dequeue(taskId); + expect(msg1).toBeDefined(); + + // Re-queue the message for cancellation test + await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); + + // Mock task as non-terminal + mockTaskStore.getTask.mockResolvedValue(task); + + // Cancel the task + transport.onmessage?.({ + jsonrpc: '2.0', + id: 200, + method: 'tasks/cancel', + params: { taskId } + }); + + // Wait for cancellation to process + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify queue is cleared (no messages available) + const msg2 = await queue!.dequeue(taskId); + expect(msg2).toBeUndefined(); + }); + + it('should reject pending request resolvers when task is cancelled', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue a request (catch rejection to avoid unhandled promise rejection) + const requestPromise = testRequest( + protocol, + { method: 'test/request', params: { data: 'test' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + // Verify request is queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Mock task as non-terminal + mockTaskStore.getTask.mockResolvedValue(task); + + // Cancel the task + transport.onmessage?.({ + jsonrpc: '2.0', + id: 201, + method: 'tasks/cancel', + params: { taskId } + }); + + // Wait for cancellation to process + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the request promise is rejected + const result = (await requestPromise) as Error; + expect(result).toBeInstanceOf(ProtocolError); + expect(result.message).toContain('Task cancelled or completed'); + + // Verify queue is cleared (no messages available) + const msg = await queue!.dequeue(taskId); + expect(msg).toBeUndefined(); + }); + }); + + describe('queue cleanup on task failure', () => { + it('should clear queue when task reaches failed status', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue some messages + await protocol.notification({ method: 'test/notification', params: { data: 'test1' } }, { relatedTask: { taskId } }); + await protocol.notification({ method: 'test/notification', params: { data: 'test2' } }, { relatedTask: { taskId } }); + + // Verify messages are queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Verify messages can be dequeued + const msg1 = await queue!.dequeue(taskId); + const msg2 = await queue!.dequeue(taskId); + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); + + // After cleanup, no more messages should be available + const msg3 = await queue!.dequeue(taskId); + expect(msg3).toBeUndefined(); + }); + + it('should reject pending request resolvers when task fails', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue a request (catch the rejection to avoid unhandled promise rejection) + const requestPromise = testRequest( + protocol, + { method: 'test/request', params: { data: 'test' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + // Verify request is queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); + + // Verify the request promise is rejected + const result = (await requestPromise) as Error; + expect(result).toBeInstanceOf(ProtocolError); + expect(result.message).toContain('Task cancelled or completed'); + + // Verify queue is cleared (no messages available) + const msg = await queue!.dequeue(taskId); + expect(msg).toBeUndefined(); + }); + }); + + describe('resolver rejection on cleanup', () => { + it('should reject all pending request resolvers when queue is cleared', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue multiple requests (catch rejections to avoid unhandled promise rejections) + const request1Promise = testRequest( + protocol, + { method: 'test/request1', params: { data: 'test1' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + const request2Promise = testRequest( + protocol, + { method: 'test/request2', params: { data: 'test2' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + const request3Promise = testRequest( + protocol, + { method: 'test/request3', params: { data: 'test3' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + // Verify requests are queued + const queue = (protocol as unknown as TestProtocolInternals)._taskManager._taskMessageQueue; + expect(queue).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); + + // Verify all request promises are rejected + const result1 = (await request1Promise) as Error; + const result2 = (await request2Promise) as Error; + const result3 = (await request3Promise) as Error; + + expect(result1).toBeInstanceOf(ProtocolError); + expect(result1.message).toContain('Task cancelled or completed'); + expect(result2).toBeInstanceOf(ProtocolError); + expect(result2.message).toContain('Task cancelled or completed'); + expect(result3).toBeInstanceOf(ProtocolError); + expect(result3.message).toContain('Task cancelled or completed'); + + // Verify queue is cleared (no messages available) + const msg = await queue!.dequeue(taskId); + expect(msg).toBeUndefined(); + }); + + it('should clean up resolver mappings when rejecting requests', async () => { + await protocol.connect(transport); + + // Create a task + const task = await mockTaskStore.createTask({}, 1, { method: 'test', params: {} }); + const taskId = task.taskId; + + // Queue a request (catch rejection to avoid unhandled promise rejection) + const requestPromise = testRequest( + protocol, + { method: 'test/request', params: { data: 'test' } }, + z.object({ result: z.string() }), + { + relatedTask: { taskId } + } + ).catch(err => err); + + // Get the request ID that was sent + const requestResolvers = (protocol as unknown as TestProtocolInternals)._taskManager._requestResolvers; + const initialResolverCount = requestResolvers.size; + expect(initialResolverCount).toBeGreaterThan(0); + + // Complete the task (triggers cleanup) + const completedTask = { ...task, status: 'completed' as const }; + mockTaskStore.getTask.mockResolvedValue(completedTask); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocolInternals)._taskManager._clearTaskQueue(taskId); + + // Verify request promise is rejected + const result = (await requestPromise) as Error; + expect(result).toBeInstanceOf(ProtocolError); + expect(result.message).toContain('Task cancelled or completed'); + + // Verify resolver mapping is cleaned up + // The resolver should be removed from the map + expect(requestResolvers.size).toBeLessThan(initialResolverCount); + }); + }); +}); + +describe('requestStream() method', () => { + const CallToolResultSchema = z.object({ + content: z.array(z.object({ type: z.string(), text: z.string() })), + _meta: z.object({}).optional() + }); + + test('should yield result immediately for non-task requests', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + // Start the request stream + const streamPromise = (async () => { + const messages = []; + const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ); + for await (const message of stream) { + messages.push(message); + } + return messages; + })(); + + // Simulate server response + await new Promise(resolve => setTimeout(resolve, 10)); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: { + content: [{ type: 'text', text: 'test result' }], + _meta: {} + } + }); + + const messages = await streamPromise; + + // Should yield exactly one result message + expect(messages).toHaveLength(1); + expect(messages[0]?.type).toBe('result'); + expect(messages[0]).toHaveProperty('result'); + }); + + test('should yield error message on request failure', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + // Start the request stream + const streamPromise = (async () => { + const messages = []; + const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ); + for await (const message of stream) { + messages.push(message); + } + return messages; + })(); + + // Simulate server error response + await new Promise(resolve => setTimeout(resolve, 10)); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Test error' + } + }); + + const messages = await streamPromise; + + // Should yield exactly one error message + expect(messages).toHaveLength(1); + expect(messages[0]?.type).toBe('error'); + expect(messages[0]).toHaveProperty('error'); + if (messages[0]?.type === 'error') { + expect(messages[0]?.error?.message).toContain('Test error'); + } + }); + + test('should handle cancellation via AbortSignal', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const abortController = new AbortController(); + + // Abort immediately before starting the stream + abortController.abort('User cancelled'); + + // Start the request stream with already-aborted signal + const messages = []; + const stream = (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema, + { + signal: abortController.signal + } + ); + for await (const message of stream) { + messages.push(message); + } + + // Should yield error message about cancellation + expect(messages).toHaveLength(1); + expect(messages[0]?.type).toBe('error'); + if (messages[0]?.type === 'error') { + expect(messages[0]?.error?.message).toContain('cancelled'); + } + }); + + describe('Error responses', () => { + test('should yield error as terminal message for server error response', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ) + ); + + // Simulate server error response + await new Promise(resolve => setTimeout(resolve, 10)); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Server error' + } + }); + + // Collect messages + const messages = await messagesPromise; + + // Verify error is terminal and last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + assertErrorResponse(lastMessage!); + expect(lastMessage.error).toBeDefined(); + expect(lastMessage.error.message).toContain('Server error'); + }); + + test('should yield error as terminal message for timeout', async () => { + vi.useFakeTimers(); + try { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema, + { + timeout: 100 + } + ) + ); + + // Advance time to trigger timeout + await vi.advanceTimersByTimeAsync(101); + + // Collect messages + const messages = await messagesPromise; + + // Verify error is terminal and last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + assertErrorResponse(lastMessage!); + expect(lastMessage.error).toBeDefined(); + expect(lastMessage.error).toBeInstanceOf(SdkError); + expect((lastMessage.error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + } finally { + vi.useRealTimers(); + } + }); + + test('should yield error as terminal message for cancellation', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const abortController = new AbortController(); + abortController.abort('User cancelled'); + + // Collect messages + const messages = await toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema, + { + signal: abortController.signal + } + ) + ); + + // Verify error is terminal and last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + assertErrorResponse(lastMessage!); + expect(lastMessage.error).toBeDefined(); + expect(lastMessage.error.message).toContain('cancelled'); + }); + + test('should not yield any messages after error message', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ) + ); + + // Simulate server error response + await new Promise(resolve => setTimeout(resolve, 10)); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Test error' + } + }); + + // Collect messages + const messages = await messagesPromise; + + // Verify only one message (the error) was yielded + expect(messages).toHaveLength(1); + expect(messages[0]?.type).toBe('error'); + + // Try to send another message (should be ignored) + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: { + content: [{ type: 'text', text: 'should not appear' }] + } + }); + + await new Promise(resolve => setTimeout(resolve, 10)); + + // Verify no additional messages were yielded + expect(messages).toHaveLength(1); + }); + + test('should yield error as terminal message for task failure', async () => { + const transport = new MockTransport(); + const mockTaskStore = createMockTaskStore(); + const protocol = createTestProtocol({ taskStore: mockTaskStore }); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ) + ); + + // Simulate task creation response + await new Promise(resolve => setTimeout(resolve, 10)); + const taskId = 'test-task-123'; + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: { + _meta: { + task: { + taskId, + status: 'working', + createdAt: new Date().toISOString(), + pollInterval: 100 + } + } + } + }); + + // Wait for task creation to be processed + await new Promise(resolve => setTimeout(resolve, 20)); + + // Update task to failed status + const failedTask = { + taskId, + status: 'failed' as const, + createdAt: new Date().toISOString(), + pollInterval: 100, + ttl: null, + statusMessage: 'Task failed' + }; + mockTaskStore.getTask.mockResolvedValue(failedTask); + + // Collect messages + const messages = await messagesPromise; + + // Verify error is terminal and last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + assertErrorResponse(lastMessage!); + expect(lastMessage.error).toBeDefined(); + }); + + test('should yield error as terminal message for network error', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + // Override send to simulate network error + transport.send = vi.fn().mockRejectedValue(new Error('Network error')); + + const messages = await toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ) + ); + + // Verify error is terminal and last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + assertErrorResponse(lastMessage!); + expect(lastMessage.error).toBeDefined(); + }); + + test('should ensure error is always the final message', async () => { + const transport = new MockTransport(); + const protocol = createTestProtocol({}); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocolInternals)._taskManager.requestStream( + { method: 'tools/call', params: { name: 'test', arguments: {} } }, + CallToolResultSchema + ) + ); + + // Simulate server error response + await new Promise(resolve => setTimeout(resolve, 10)); + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Test error' + } + }); + + // Collect messages + const messages = await messagesPromise; + + // Verify error is the last message + expect(messages.length).toBeGreaterThan(0); + const lastMessage = messages[messages.length - 1]; + expect(lastMessage?.type).toBe('error'); + + // Verify all messages before the last are not terminal + for (let i = 0; i < messages.length - 1; i++) { + expect(messages[i]?.type).not.toBe('error'); + expect(messages[i]?.type).not.toBe('result'); + } + }); + }); +}); + +describe('Error handling for missing resolvers', () => { + let protocol: Protocol; + let transport: MockTransport; + let taskStore: TaskStore & { [K in keyof TaskStore]: MockInstance }; + let taskMessageQueue: TaskMessageQueue; + let errorHandler: MockInstance; + + beforeEach(() => { + taskStore = createMockTaskStore(); + taskMessageQueue = new InMemoryTaskMessageQueue(); + errorHandler = vi.fn(); + + protocol = createTestProtocol({ taskStore, taskMessageQueue, defaultTaskPollInterval: 100 }); + + // @ts-expect-error deliberately overriding error handler with mock + protocol.onerror = errorHandler; + transport = new MockTransport(); + }); + + describe('Response routing with missing resolvers', () => { + it('should log error for unknown request ID without throwing', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + // Enqueue a response message without a corresponding resolver + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: 999, // Non-existent request ID + result: { content: [] } + }, + timestamp: Date.now() + }); + + // Set up the GetTaskPayloadRequest handler to process the message + const testProtocol = protocol as unknown as TestProtocolInternals; + + // Simulate dequeuing and processing the response + const queuedMessage = await taskMessageQueue.dequeue(task.taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage?.type).toBe('response'); + + // Manually trigger the response handling logic + if (queuedMessage && queuedMessage.type === 'response') { + const responseMessage = queuedMessage.message as JSONRPCResultResponse; + const requestId = responseMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + + if (!resolver) { + // This simulates what happens in the actual handler + protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); + } + } + + // Verify error was logged + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Response handler missing for request 999') + }) + ); + }); + + it('should continue processing after missing resolver error', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + // Enqueue a response with missing resolver, then a valid notification + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: 999, + result: { content: [] } + }, + timestamp: Date.now() + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'notification', + message: { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progress: 50, total: 100 } + }, + timestamp: Date.now() + }); + + // Process first message (response with missing resolver) + const msg1 = await taskMessageQueue.dequeue(task.taskId); + expect(msg1?.type).toBe('response'); + + // Process second message (should work fine) + const msg2 = await taskMessageQueue.dequeue(task.taskId); + expect(msg2?.type).toBe('notification'); + expect(msg2?.message).toMatchObject({ + method: 'notifications/progress' + }); + }); + }); + + describe('Task cancellation with missing resolvers', () => { + it('should log error when resolver is missing during cleanup', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + // Enqueue a request without storing a resolver + await taskMessageQueue.enqueue(task.taskId, { + type: 'request', + message: { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }); + + // Clear the task queue (simulating cancellation) + const testProtocol = protocol as unknown as TestProtocolInternals; + await testProtocol._taskManager._clearTaskQueue(task.taskId); + + // Verify error was logged for missing resolver + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Resolver missing for request 42') + }) + ); + }); + + it('should handle cleanup gracefully when resolver exists', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + const requestId = 42; + const resolverMock = vi.fn(); + + // Store a resolver + const testProtocol = protocol as unknown as TestProtocolInternals; + testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); + + // Enqueue a request + await taskMessageQueue.enqueue(task.taskId, { + type: 'request', + message: { + jsonrpc: '2.0', + id: requestId, + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }); + + // Clear the task queue + await testProtocol._taskManager._clearTaskQueue(task.taskId); + + // Verify resolver was called with cancellation error + expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); + + // Verify the error has the correct properties + const calledError = resolverMock.mock.calls[0]![0]; + expect(calledError.code).toBe(ProtocolErrorCode.InternalError); + expect(calledError.message).toContain('Task cancelled or completed'); + + // Verify resolver was removed + expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); + }); + + it('should handle mixed messages during cleanup', async () => { + await protocol.connect(transport); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + const testProtocol = protocol as unknown as TestProtocolInternals; + + // Enqueue multiple messages: request with resolver, request without, notification + const requestId1 = 42; + const resolverMock = vi.fn(); + testProtocol._taskManager._requestResolvers.set(requestId1, resolverMock); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'request', + message: { + jsonrpc: '2.0', + id: requestId1, + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'request', + message: { + jsonrpc: '2.0', + id: 43, // No resolver for this one + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'notification', + message: { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progress: 50, total: 100 } + }, + timestamp: Date.now() + }); + + // Clear the task queue + await testProtocol._taskManager._clearTaskQueue(task.taskId); + + // Verify resolver was called for first request + expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); + + // Verify the error has the correct properties + const calledError = resolverMock.mock.calls[0]![0]; + expect(calledError.code).toBe(ProtocolErrorCode.InternalError); + expect(calledError.message).toContain('Task cancelled or completed'); + + // Verify error was logged for second request + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Resolver missing for request 43') + }) + ); + + // Verify queue is empty + const remaining = await taskMessageQueue.dequeue(task.taskId); + expect(remaining).toBeUndefined(); + }); + }); + + describe('Side-channeled request error handling', () => { + it('should log error when response handler is missing for side-channeled request', async () => { + await protocol.connect(transport); + + const testProtocol = protocol as unknown as TestProtocolInternals; + const messageId = 123; + + // Create a response resolver without a corresponding response handler + const responseResolver = (response: JSONRPCResultResponse | Error) => { + const handler = testProtocol._responseHandlers.get(messageId); + if (handler) { + handler(response); + } else { + protocol.onerror?.(new Error(`Response handler missing for side-channeled request ${messageId}`)); + } + }; + + // Simulate the resolver being called without a handler + const mockResponse: JSONRPCResultResponse = { + jsonrpc: '2.0', + id: messageId, + result: { content: [] } + }; + + responseResolver(mockResponse); + + // Verify error was logged + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Response handler missing for side-channeled request 123') + }) + ); + }); + }); + + describe('Error handling does not throw exceptions', () => { + it('should not throw when processing response with missing resolver', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: 999, + result: { content: [] } + }, + timestamp: Date.now() + }); + + // This should not throw + const processMessage = async () => { + const msg = await taskMessageQueue.dequeue(task.taskId); + if (msg && msg.type === 'response') { + const testProtocol = protocol as unknown as TestProtocolInternals; + const responseMessage = msg.message as JSONRPCResultResponse; + const requestId = responseMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (!resolver) { + protocol.onerror?.(new Error(`Response handler missing for request ${requestId}`)); + } + } + }; + + await expect(processMessage()).resolves.not.toThrow(); + }); + + it('should not throw during task cleanup with missing resolvers', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'request', + message: { + jsonrpc: '2.0', + id: 42, + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }, + timestamp: Date.now() + }); + + const testProtocol = protocol as unknown as TestProtocolInternals; + + // This should not throw + await expect(testProtocol._taskManager._clearTaskQueue(task.taskId)).resolves.not.toThrow(); + }); + }); + + describe('Error message routing', () => { + it('should route error messages to resolvers correctly', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + const requestId = 42; + const resolverMock = vi.fn(); + + // Store a resolver + const testProtocol = protocol as unknown as TestProtocolInternals; + testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); + + // Enqueue an error message + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: requestId, + error: { + code: ProtocolErrorCode.InvalidRequest, + message: 'Invalid request parameters' + } + }, + timestamp: Date.now() + }); + + // Simulate dequeuing and processing the error + const queuedMessage = await taskMessageQueue.dequeue(task.taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage?.type).toBe('error'); + + // Manually trigger the error handling logic + if (queuedMessage && queuedMessage.type === 'error') { + const errorMessage = queuedMessage.message as JSONRPCErrorResponse; + const reqId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(reqId); + + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(reqId); + const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + + // Verify resolver was called with ProtocolError + expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); + const calledError = resolverMock.mock.calls[0]![0]; + expect(calledError.code).toBe(ProtocolErrorCode.InvalidRequest); + expect(calledError.message).toContain('Invalid request parameters'); + + // Verify resolver was removed from map + expect(testProtocol._taskManager._requestResolvers.has(requestId)).toBe(false); + }); + + it('should log error for unknown request ID in error messages', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + // Enqueue an error message without a corresponding resolver + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: 999, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Something went wrong' + } + }, + timestamp: Date.now() + }); + + // Simulate dequeuing and processing the error + const queuedMessage = await taskMessageQueue.dequeue(task.taskId); + expect(queuedMessage).toBeDefined(); + expect(queuedMessage?.type).toBe('error'); + + // Manually trigger the error handling logic + if (queuedMessage && queuedMessage.type === 'error') { + const testProtocol = protocol as unknown as TestProtocolInternals; + const errorMessage = queuedMessage.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + + if (!resolver) { + protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); + } + } + + // Verify error was logged + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Error handler missing for request 999') + }) + ); + }); + + it('should handle error messages with data field', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + const requestId = 42; + const resolverMock = vi.fn(); + + // Store a resolver + const testProtocol = protocol as unknown as TestProtocolInternals; + testProtocol._taskManager._requestResolvers.set(requestId, resolverMock); + + // Enqueue an error message with data field + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: requestId, + error: { + code: ProtocolErrorCode.InvalidParams, + message: 'Validation failed', + data: { field: 'userName', reason: 'required' } + } + }, + timestamp: Date.now() + }); + + // Simulate dequeuing and processing the error + const queuedMessage = await taskMessageQueue.dequeue(task.taskId); + + if (queuedMessage && queuedMessage.type === 'error') { + const errorMessage = queuedMessage.message as JSONRPCErrorResponse; + const reqId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(reqId); + + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(reqId); + const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + + // Verify resolver was called with ProtocolError including data + expect(resolverMock).toHaveBeenCalledWith(expect.any(ProtocolError)); + const calledError = resolverMock.mock.calls[0]![0]; + expect(calledError.code).toBe(ProtocolErrorCode.InvalidParams); + expect(calledError.message).toContain('Validation failed'); + expect(calledError.data).toEqual({ field: 'userName', reason: 'required' }); + }); + + it('should not throw when processing error with missing resolver', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: 999, + error: { + code: ProtocolErrorCode.InternalError, + message: 'Error occurred' + } + }, + timestamp: Date.now() + }); + + // This should not throw + const processMessage = async () => { + const msg = await taskMessageQueue.dequeue(task.taskId); + if (msg && msg.type === 'error') { + const testProtocol = protocol as unknown as TestProtocolInternals; + const errorMessage = msg.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (!resolver) { + protocol.onerror?.(new Error(`Error handler missing for request ${requestId}`)); + } + } + }; + + await expect(processMessage()).resolves.not.toThrow(); + }); + }); + + describe('Response and error message routing integration', () => { + it('should handle mixed response and error messages in queue', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + const testProtocol = protocol as unknown as TestProtocolInternals; + + // Set up resolvers for multiple requests + const resolver1 = vi.fn(); + const resolver2 = vi.fn(); + const resolver3 = vi.fn(); + + testProtocol._taskManager._requestResolvers.set(1, resolver1); + testProtocol._taskManager._requestResolvers.set(2, resolver2); + testProtocol._taskManager._requestResolvers.set(3, resolver3); + + // Enqueue mixed messages: response, error, response + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'Success' }] } + }, + timestamp: Date.now() + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: 2, + error: { + code: ProtocolErrorCode.InvalidRequest, + message: 'Request failed' + } + }, + timestamp: Date.now() + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { + jsonrpc: '2.0', + id: 3, + result: { content: [{ type: 'text', text: 'Another success' }] } + }, + timestamp: Date.now() + }); + + // Process all messages + let msg; + while ((msg = await taskMessageQueue.dequeue(task.taskId))) { + if (msg.type === 'response') { + const responseMessage = msg.message as JSONRPCResultResponse; + const requestId = responseMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(requestId); + resolver(responseMessage); + } + } else if (msg.type === 'error') { + const errorMessage = msg.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(requestId); + const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + } + + // Verify all resolvers were called correctly + expect(resolver1).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + expect(resolver2).toHaveBeenCalledWith(expect.any(ProtocolError)); + expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + + // Verify error has correct properties + const error = resolver2.mock.calls[0]![0]; + expect(error.code).toBe(ProtocolErrorCode.InvalidRequest); + expect(error.message).toContain('Request failed'); + + // Verify all resolvers were removed + expect(testProtocol._taskManager._requestResolvers.size).toBe(0); + }); + + it('should maintain FIFO order when processing responses and errors', async () => { + await protocol.connect(transport); + + const task = await taskStore.createTask({ ttl: 60000 }, 1, { method: 'test', params: {} }); + const testProtocol = protocol as unknown as TestProtocolInternals; + + const callOrder: number[] = []; + const resolver1 = vi.fn(() => callOrder.push(1)); + const resolver2 = vi.fn(() => callOrder.push(2)); + const resolver3 = vi.fn(() => callOrder.push(3)); + + testProtocol._taskManager._requestResolvers.set(1, resolver1); + testProtocol._taskManager._requestResolvers.set(2, resolver2); + testProtocol._taskManager._requestResolvers.set(3, resolver3); + + // Enqueue in specific order + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { jsonrpc: '2.0', id: 1, result: {} }, + timestamp: 1000 + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: 2, + error: { code: -32600, message: 'Error' } + }, + timestamp: 2000 + }); + + await taskMessageQueue.enqueue(task.taskId, { + type: 'response', + message: { jsonrpc: '2.0', id: 3, result: {} }, + timestamp: 3000 + }); + + // Process all messages + let msg; + while ((msg = await taskMessageQueue.dequeue(task.taskId))) { + if (msg.type === 'response') { + const responseMessage = msg.message as JSONRPCResultResponse; + const requestId = responseMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(requestId); + resolver(responseMessage); + } + } else if (msg.type === 'error') { + const errorMessage = msg.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._taskManager._requestResolvers.get(requestId); + if (resolver) { + testProtocol._taskManager._requestResolvers.delete(requestId); + const error = new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + } + + // Verify FIFO order was maintained + expect(callOrder).toEqual([1, 2, 3]); + }); + }); +}); + +describe('Protocol without task configuration', () => { + let protocol: TestProtocolImpl; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = createTestProtocol(); // empty TaskManager options + }); + + test('request/response flow works normally without task config', async () => { + await protocol.connect(transport); + const mockSchema = z.object({ result: z.string() }); + + const requestPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { timeout: 5000 }); + + // Simulate response + transport.onmessage?.({ + jsonrpc: '2.0', + id: 0, + result: { result: 'hello' } + }); + + const result = await requestPromise; + expect(result).toEqual({ result: 'hello' }); + }); + + test('notifications are sent with proper JSONRPC wrapping without task config', async () => { + await protocol.connect(transport); + + await protocol.notification({ method: 'notifications/cancelled', params: { requestId: '1', reason: 'test' } }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: '1', reason: 'test' } + }), + undefined + ); + }); + + test('onClose does not error without task config', async () => { + await protocol.connect(transport); + await expect(protocol.close()).resolves.not.toThrow(); + }); + + test('inbound requests dispatch to handlers without task config', async () => { + const handler = vi.fn().mockResolvedValue({ content: 'ok' }); + protocol.setRequestHandler('ping', handler); + + await protocol.connect(transport); + transport.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 1 }); + + // Wait for async handler + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(handler).toHaveBeenCalled(); + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + id: 1, + result: { content: 'ok' } + }) + ); + }); +}); + +describe('TaskManager lifecycle via Protocol', () => { + let protocol: TestProtocolImpl; + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + protocol = new TestProtocolImpl(); + }); + + test('bind() is called during Protocol construction', () => { + const bindSpy = vi.spyOn(TaskManager.prototype, 'bind'); + const p = new TestProtocolImpl({ tasks: {} }); + expect(bindSpy).toHaveBeenCalled(); + expect(p.taskManager).toBeInstanceOf(TaskManager); + bindSpy.mockRestore(); + }); + + test('NullTaskManager is created when no tasks config is provided', () => { + const p = new TestProtocolImpl(); + expect(p.taskManager).toBeInstanceOf(NullTaskManager); + }); + + test('onClose() is called when transport closes', async () => { + const p = createTestProtocol({}); + const onCloseSpy = vi.spyOn(p.taskManager, 'onClose'); + + await p.connect(transport); + await p.close(); + + expect(onCloseSpy).toHaveBeenCalled(); + }); +}); + +describe('TaskManager always present (NullTaskManager pattern)', () => { + test('taskManager accessor always returns a TaskManager', () => { + const mockTaskModule = { getTask: vi.fn() }; + const mockClient = { taskManager: mockTaskModule } as any; + expect(mockClient.taskManager).toBe(mockTaskModule); + }); +}); diff --git a/packages/core/test/shared/protocolTransportHandling.test.ts b/packages/core/test/shared/protocolTransportHandling.test.ts new file mode 100644 index 0000000..4e9c33e --- /dev/null +++ b/packages/core/test/shared/protocolTransportHandling.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, test } from 'vitest'; + +import type { BaseContext } from '../../src/shared/protocol.js'; +import { Protocol } from '../../src/shared/protocol.js'; +import type { Transport } from '../../src/shared/transport.js'; +import type { EmptyResult, JSONRPCMessage, Notification, Request, Result } from '../../src/types/index.js'; + +// Mock Transport class +class MockTransport implements Transport { + id: string; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: unknown) => void; + sentMessages: JSONRPCMessage[] = []; + + constructor(id: string) { + this.id = id; + } + + async start(): Promise {} + + async close(): Promise { + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + this.sentMessages.push(message); + } +} + +describe('Protocol transport handling bug', () => { + let protocol: Protocol; + let transportA: MockTransport; + let transportB: MockTransport; + + beforeEach(() => { + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + })(); + + transportA = new MockTransport('A'); + transportB = new MockTransport('B'); + }); + + test('should send response to the correct transport when multiple clients are connected', async () => { + // Set up a request handler that simulates processing time + let resolveHandler: (value: EmptyResult) => void; + const handlerPromise = new Promise(resolve => { + resolveHandler = resolve; + }); + + protocol.setRequestHandler('ping', async () => handlerPromise); + + // Client A connects and sends a request + await protocol.connect(transportA); + transportA.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 1 }); + + // While A's request is being processed, client B connects + // This overwrites the transport reference in the protocol + await protocol.connect(transportB); + transportB.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 2 }); + + // Now complete A's request + resolveHandler!({}); + + // Wait for async operations to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + // Check where the responses went + console.log('Transport A received:', transportA.sentMessages); + console.log('Transport B received:', transportB.sentMessages); + + // Transport A should receive response for request ID 1 + expect(transportA.sentMessages).toHaveLength(1); + expect(transportA.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', id: 1, result: {} }); + + // Transport B should receive response for request ID 2 + expect(transportB.sentMessages).toHaveLength(1); + expect(transportB.sentMessages[0]).toMatchObject({ jsonrpc: '2.0', id: 2, result: {} }); + }); + + test('demonstrates the timing issue with multiple rapid connections', async () => { + const results: { transport: string; response: JSONRPCMessage[] }[] = []; + + // Set up handler with variable delay based on request id + protocol.setRequestHandler('ping', async (_request, ctx) => { + const delay = ctx.mcpReq.id === 1 ? 50 : 10; + await new Promise(resolve => setTimeout(resolve, delay)); + return {}; + }); + + // Rapid succession of connections and requests + await protocol.connect(transportA); + transportA.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 1 }); + + // Connect B while A is processing + setTimeout(async () => { + await protocol.connect(transportB); + transportB.onmessage?.({ jsonrpc: '2.0', method: 'ping', id: 2 }); + }, 10); + + // Wait for all processing + await new Promise(resolve => setTimeout(resolve, 100)); + + // Collect results + if (transportA.sentMessages.length > 0) { + results.push({ transport: 'A', response: transportA.sentMessages }); + } + if (transportB.sentMessages.length > 0) { + results.push({ transport: 'B', response: transportB.sentMessages }); + } + + console.log('Timing test results:', results); + + expect(transportA.sentMessages).toHaveLength(1); + expect(transportB.sentMessages).toHaveLength(1); + }); +}); diff --git a/packages/core/test/shared/stdio.test.ts b/packages/core/test/shared/stdio.test.ts new file mode 100644 index 0000000..65d1de0 --- /dev/null +++ b/packages/core/test/shared/stdio.test.ts @@ -0,0 +1,115 @@ +import { ReadBuffer } from '../../src/shared/stdio.js'; +import type { JSONRPCMessage } from '../../src/types/index.js'; + +const testMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'foobar' +}; + +test('should have no messages after initialization', () => { + const readBuffer = new ReadBuffer(); + expect(readBuffer.readMessage()).toBeNull(); +}); + +test('should only yield a message after a newline', () => { + const readBuffer = new ReadBuffer(); + + readBuffer.append(Buffer.from(JSON.stringify(testMessage))); + expect(readBuffer.readMessage()).toBeNull(); + + readBuffer.append(Buffer.from('\n')); + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); +}); + +test('should be reusable after clearing', () => { + const readBuffer = new ReadBuffer(); + + readBuffer.append(Buffer.from('foobar')); + readBuffer.clear(); + expect(readBuffer.readMessage()).toBeNull(); + + readBuffer.append(Buffer.from(JSON.stringify(testMessage))); + readBuffer.append(Buffer.from('\n')); + expect(readBuffer.readMessage()).toEqual(testMessage); +}); + +describe('non-JSON line filtering', () => { + test('should skip empty lines', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('\n\n' + JSON.stringify(testMessage) + '\n\n')); + + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should skip non-JSON lines before a valid message', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('Debug: Starting server\n' + 'Warning: Something happened\n' + JSON.stringify(testMessage) + '\n')); + + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should skip non-JSON lines interleaved with multiple valid messages', () => { + const readBuffer = new ReadBuffer(); + const message1: JSONRPCMessage = { jsonrpc: '2.0', method: 'method1' }; + const message2: JSONRPCMessage = { jsonrpc: '2.0', method: 'method2' }; + + readBuffer.append( + Buffer.from( + 'Debug line 1\n' + + JSON.stringify(message1) + + '\n' + + 'Debug line 2\n' + + 'Another non-JSON line\n' + + JSON.stringify(message2) + + '\n' + ) + ); + + expect(readBuffer.readMessage()).toEqual(message1); + expect(readBuffer.readMessage()).toEqual(message2); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should preserve incomplete JSON at end of buffer until completed', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('{"jsonrpc": "2.0", "method": "test"')); + expect(readBuffer.readMessage()).toBeNull(); + + readBuffer.append(Buffer.from('}\n')); + expect(readBuffer.readMessage()).toEqual({ jsonrpc: '2.0', method: 'test' }); + }); + + test('should skip lines with unbalanced braces', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('{incomplete\n' + 'incomplete}\n' + JSON.stringify(testMessage) + '\n')); + + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should skip lines that look like JSON but fail to parse', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('{invalidJson: true}\n' + JSON.stringify(testMessage) + '\n')); + + expect(readBuffer.readMessage()).toEqual(testMessage); + expect(readBuffer.readMessage()).toBeNull(); + }); + + test('should tolerate leading/trailing whitespace around valid JSON', () => { + const readBuffer = new ReadBuffer(); + const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test' }; + readBuffer.append(Buffer.from(' ' + JSON.stringify(message) + ' \n')); + + expect(readBuffer.readMessage()).toEqual(message); + }); + + test('should still throw on valid JSON that fails schema validation', () => { + const readBuffer = new ReadBuffer(); + readBuffer.append(Buffer.from('{"not": "a jsonrpc message"}\n')); + + expect(() => readBuffer.readMessage()).toThrow(); + }); +}); diff --git a/packages/core/test/shared/toolNameValidation.test.ts b/packages/core/test/shared/toolNameValidation.test.ts new file mode 100644 index 0000000..131cbbc --- /dev/null +++ b/packages/core/test/shared/toolNameValidation.test.ts @@ -0,0 +1,130 @@ +import type { MockInstance } from 'vitest'; +import { vi } from 'vitest'; + +import { issueToolNameWarning, validateAndWarnToolName, validateToolName } from '../../src/shared/toolNameValidation.js'; + +// Spy on console.warn to capture output +let warnSpy: MockInstance; + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('validateToolName', () => { + describe('valid tool names', () => { + test.each` + description | toolName + ${'simple alphanumeric names'} | ${'getUser'} + ${'names with underscores'} | ${'get_user_profile'} + ${'names with dashes'} | ${'user-profile-update'} + ${'names with dots'} | ${'admin.tools.list'} + ${'mixed character names'} | ${'DATA_EXPORT_v2.1'} + ${'single character names'} | ${'a'} + ${'128 character names'} | ${'a'.repeat(128)} + `('should accept $description', ({ toolName }) => { + const result = validateToolName(toolName); + expect(result.isValid).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('invalid tool names', () => { + test.each` + description | toolName | expectedWarning + ${'empty names'} | ${''} | ${'Tool name cannot be empty'} + ${'names longer than 128 characters'} | ${'a'.repeat(129)} | ${'Tool name exceeds maximum length of 128 characters (current: 129)'} + ${'names with spaces'} | ${'get user profile'} | ${'Tool name contains invalid characters: " "'} + ${'names with commas'} | ${'get,user,profile'} | ${'Tool name contains invalid characters: ","'} + ${'names with forward slashes'} | ${'user/profile/update'} | ${'Tool name contains invalid characters: "/"'} + ${'names with other special chars'} | ${'user@domain.com'} | ${'Tool name contains invalid characters: "@"'} + ${'names with multiple invalid chars'} | ${'user name@domain,com'} | ${'Tool name contains invalid characters: " ", "@", ","'} + ${'names with unicode characters'} | ${'user-ñame'} | ${'Tool name contains invalid characters: "ñ"'} + `('should reject $description', ({ toolName, expectedWarning }) => { + const result = validateToolName(toolName); + expect(result.isValid).toBe(false); + expect(result.warnings).toContain(expectedWarning); + }); + }); + + describe('warnings for potentially problematic patterns', () => { + test.each` + description | toolName | expectedWarning | shouldBeValid + ${'names with spaces'} | ${'get user profile'} | ${'Tool name contains spaces, which may cause parsing issues'} | ${false} + ${'names with commas'} | ${'get,user,profile'} | ${'Tool name contains commas, which may cause parsing issues'} | ${false} + ${'names starting with dash'} | ${'-get-user'} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'} | ${true} + ${'names ending with dash'} | ${'get-user-'} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'} | ${true} + ${'names starting with dot'} | ${'.get.user'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true} + ${'names ending with dot'} | ${'get.user.'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true} + ${'names with leading and trailing dots'} | ${'.get.user.'} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} | ${true} + `('should warn about $description', ({ toolName, expectedWarning, shouldBeValid }) => { + const result = validateToolName(toolName); + expect(result.isValid).toBe(shouldBeValid); + expect(result.warnings).toContain(expectedWarning); + }); + }); +}); + +describe('issueToolNameWarning', () => { + test('should output warnings to console.warn', () => { + const warnings = ['Warning 1', 'Warning 2']; + issueToolNameWarning('test-tool', warnings); + + expect(warnSpy).toHaveBeenCalledTimes(6); // Header + 2 warnings + 3 guidance lines + const calls = warnSpy.mock.calls.map(call => call.join(' ')); + expect(calls[0]).toContain('Tool name validation warning for "test-tool"'); + expect(calls[1]).toContain('- Warning 1'); + expect(calls[2]).toContain('- Warning 2'); + expect(calls[3]).toContain('Tool registration will proceed, but this may cause compatibility issues.'); + expect(calls[4]).toContain('Consider updating the tool name'); + expect(calls[5]).toContain('See SEP: Specify Format for Tool Names'); + }); + + test('should handle empty warnings array', () => { + issueToolNameWarning('test-tool', []); + expect(warnSpy).toHaveBeenCalledTimes(0); + }); +}); + +describe('validateAndWarnToolName', () => { + test.each` + description | toolName | expectedResult | shouldWarn + ${'valid names with warnings'} | ${'-get-user-'} | ${true} | ${true} + ${'completely valid names'} | ${'get-user-profile'} | ${true} | ${false} + ${'invalid names with spaces'} | ${'get user profile'} | ${false} | ${true} + ${'empty names'} | ${''} | ${false} | ${true} + ${'names exceeding length limit'} | ${'a'.repeat(129)} | ${false} | ${true} + `('should handle $description', ({ toolName, expectedResult, shouldWarn }) => { + const result = validateAndWarnToolName(toolName); + expect(result).toBe(expectedResult); + + if (shouldWarn) { + expect(warnSpy).toHaveBeenCalled(); + } else { + expect(warnSpy).not.toHaveBeenCalled(); + } + }); + + test('should include space warning for invalid names with spaces', () => { + validateAndWarnToolName('get user profile'); + const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); + expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); + }); +}); + +describe('edge cases and robustness', () => { + test.each` + description | toolName | shouldBeValid | expectedWarning + ${'names with only dots'} | ${'...'} | ${true} | ${'Tool name starts or ends with a dot, which may cause parsing issues in some contexts'} + ${'names with only dashes'} | ${'---'} | ${true} | ${'Tool name starts or ends with a dash, which may cause parsing issues in some contexts'} + ${'names with only forward slashes'} | ${'///'} | ${false} | ${'Tool name contains invalid characters: "/"'} + ${'names with mixed valid/invalid chars'} | ${'user@name123'} | ${false} | ${'Tool name contains invalid characters: "@"'} + `('should handle $description', ({ toolName, shouldBeValid, expectedWarning }) => { + const result = validateToolName(toolName); + expect(result.isValid).toBe(shouldBeValid); + expect(result.warnings).toContain(expectedWarning); + }); +}); diff --git a/packages/core/test/shared/transport.test.ts b/packages/core/test/shared/transport.test.ts new file mode 100644 index 0000000..bdef03a --- /dev/null +++ b/packages/core/test/shared/transport.test.ts @@ -0,0 +1,182 @@ +import { createFetchWithInit, type FetchLike, normalizeHeaders } from '../../src/shared/transport.js'; + +describe('normalizeHeaders', () => { + test('returns empty object for undefined', () => { + expect(normalizeHeaders(undefined)).toEqual({}); + }); + + test('handles Headers instance', () => { + const headers = new Headers({ + 'x-foo': 'bar', + 'content-type': 'application/json' + }); + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'content-type': 'application/json' + }); + }); + + test('handles array of tuples', () => { + const headers: [string, string][] = [ + ['x-foo', 'bar'], + ['x-baz', 'qux'] + ]; + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'x-baz': 'qux' + }); + }); + + test('handles plain object', () => { + const headers = { 'x-foo': 'bar', 'x-baz': 'qux' }; + expect(normalizeHeaders(headers)).toEqual({ + 'x-foo': 'bar', + 'x-baz': 'qux' + }); + }); + + test('returns a shallow copy for plain objects', () => { + const headers = { 'x-foo': 'bar' }; + const result = normalizeHeaders(headers); + expect(result).not.toBe(headers); + expect(result).toEqual(headers); + }); +}); + +describe('createFetchWithInit', () => { + test('returns baseFetch unchanged when no baseInit provided', () => { + const mockFetch: FetchLike = vi.fn(); + const result = createFetchWithInit(mockFetch); + expect(result).toBe(mockFetch); + }); + + test('passes baseInit to fetch when no call init provided', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include' + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + credentials: 'include' + }) + ); + }); + + test('merges baseInit with call init, call init wins for non-header fields', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include' + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'PUT' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'PUT', + credentials: 'include' + }) + ); + }); + + test('merges headers from both base and call init', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + headers: { 'x-base': 'base-value', 'x-shared': 'base' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { + headers: { 'x-call': 'call-value', 'x-shared': 'call' } + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + headers: { + 'x-base': 'base-value', + 'x-call': 'call-value', + 'x-shared': 'call' + } + }) + ); + }); + + test('uses baseInit headers when call init has no headers', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + headers: { 'x-base': 'base-value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'POST' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + headers: { 'x-base': 'base-value' } + }) + ); + }); + + test('handles URL object as first argument', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { method: 'GET' }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + const url = new URL('https://example.com/path'); + await wrappedFetch(url); + + expect(mockFetch).toHaveBeenCalledWith(url, expect.objectContaining({ method: 'GET' })); + }); + + test('passes all baseInit properties when call init is empty object', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseInit: RequestInit = { + method: 'POST', + credentials: 'include', + headers: { 'x-base': 'value' } + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', {}); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + headers: { 'x-base': 'value' } + }) + ); + }); + + test('passes Headers instance through when call init has no headers', async () => { + const mockFetch: FetchLike = vi.fn(); + const baseHeaders = new Headers({ 'x-base': 'value' }); + const baseInit: RequestInit = { + headers: baseHeaders + }; + + const wrappedFetch = createFetchWithInit(mockFetch, baseInit); + await wrappedFetch('https://example.com', { method: 'POST' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com', + expect.objectContaining({ + method: 'POST', + headers: baseHeaders + }) + ); + }); +}); diff --git a/packages/core/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts new file mode 100644 index 0000000..3954901 --- /dev/null +++ b/packages/core/test/shared/uriTemplate.test.ts @@ -0,0 +1,314 @@ +import { UriTemplate } from '../../src/shared/uriTemplate.js'; + +describe('UriTemplate', () => { + describe('isTemplate', () => { + it('should return true for strings containing template expressions', () => { + expect(UriTemplate.isTemplate('{foo}')).toBe(true); + expect(UriTemplate.isTemplate('/users/{id}')).toBe(true); + expect(UriTemplate.isTemplate('http://example.com/{path}/{file}')).toBe(true); + expect(UriTemplate.isTemplate('/search{?q,limit}')).toBe(true); + }); + + it('should return false for strings without template expressions', () => { + expect(UriTemplate.isTemplate('')).toBe(false); + expect(UriTemplate.isTemplate('plain string')).toBe(false); + expect(UriTemplate.isTemplate('http://example.com/foo/bar')).toBe(false); + expect(UriTemplate.isTemplate('{}')).toBe(false); // Empty braces don't count + expect(UriTemplate.isTemplate('{ }')).toBe(false); // Just whitespace doesn't count + }); + }); + + describe('simple string expansion', () => { + it('should expand simple string variables', () => { + const template = new UriTemplate('http://example.com/users/{username}'); + expect(template.expand({ username: 'fred' })).toBe('http://example.com/users/fred'); + expect(template.variableNames).toEqual(['username']); + }); + + it('should handle multiple variables', () => { + const template = new UriTemplate('{x,y}'); + expect(template.expand({ x: '1024', y: '768' })).toBe('1024,768'); + expect(template.variableNames).toEqual(['x', 'y']); + }); + + it('should encode reserved characters', () => { + const template = new UriTemplate('{var}'); + expect(template.expand({ var: 'value with spaces' })).toBe('value%20with%20spaces'); + }); + }); + + describe('reserved expansion', () => { + it('should not encode reserved characters with + operator', () => { + const template = new UriTemplate('{+path}/here'); + expect(template.expand({ path: '/foo/bar' })).toBe('/foo/bar/here'); + expect(template.variableNames).toEqual(['path']); + }); + }); + + describe('fragment expansion', () => { + it('should add # prefix and not encode reserved chars', () => { + const template = new UriTemplate('X{#var}'); + expect(template.expand({ var: '/test' })).toBe('X#/test'); + expect(template.variableNames).toEqual(['var']); + }); + }); + + describe('label expansion', () => { + it('should add . prefix', () => { + const template = new UriTemplate('X{.var}'); + expect(template.expand({ var: 'test' })).toBe('X.test'); + expect(template.variableNames).toEqual(['var']); + }); + }); + + describe('path expansion', () => { + it('should add / prefix', () => { + const template = new UriTemplate('X{/var}'); + expect(template.expand({ var: 'test' })).toBe('X/test'); + expect(template.variableNames).toEqual(['var']); + }); + }); + + describe('query expansion', () => { + it('should add ? prefix and name=value format', () => { + const template = new UriTemplate('X{?var}'); + expect(template.expand({ var: 'test' })).toBe('X?var=test'); + expect(template.variableNames).toEqual(['var']); + }); + }); + + describe('form continuation expansion', () => { + it('should add & prefix and name=value format', () => { + const template = new UriTemplate('X{&var}'); + expect(template.expand({ var: 'test' })).toBe('X&var=test'); + expect(template.variableNames).toEqual(['var']); + }); + }); + + describe('matching', () => { + it('should match simple strings and extract variables', () => { + const template = new UriTemplate('http://example.com/users/{username}'); + const match = template.match('http://example.com/users/fred'); + expect(match).toEqual({ username: 'fred' }); + }); + + it('should match multiple variables', () => { + const template = new UriTemplate('/users/{username}/posts/{postId}'); + const match = template.match('/users/fred/posts/123'); + expect(match).toEqual({ username: 'fred', postId: '123' }); + }); + + it('should return null for non-matching URIs', () => { + const template = new UriTemplate('/users/{username}'); + const match = template.match('/posts/123'); + expect(match).toBeNull(); + }); + + it('should handle exploded arrays', () => { + const template = new UriTemplate('{/list*}'); + const match = template.match('/red,green,blue'); + expect(match).toEqual({ list: ['red', 'green', 'blue'] }); + }); + }); + + describe('edge cases', () => { + it('should handle empty variables', () => { + const template = new UriTemplate('{empty}'); + expect(template.expand({})).toBe(''); + expect(template.expand({ empty: '' })).toBe(''); + }); + + it('should handle undefined variables', () => { + const template = new UriTemplate('{a}{b}{c}'); + expect(template.expand({ b: '2' })).toBe('2'); + }); + + it('should handle special characters in variable names', () => { + const template = new UriTemplate('{$var_name}'); + expect(template.expand({ $var_name: 'value' })).toBe('value'); + }); + }); + + describe('complex patterns', () => { + it('should handle nested path segments', () => { + const template = new UriTemplate('/api/{version}/{resource}/{id}'); + expect( + template.expand({ + version: 'v1', + resource: 'users', + id: '123' + }) + ).toBe('/api/v1/users/123'); + expect(template.variableNames).toEqual(['version', 'resource', 'id']); + }); + + it('should handle query parameters with arrays', () => { + const template = new UriTemplate('/search{?tags*}'); + expect( + template.expand({ + tags: ['nodejs', 'typescript', 'testing'] + }) + ).toBe('/search?tags=nodejs,typescript,testing'); + expect(template.variableNames).toEqual(['tags']); + }); + + it('should handle multiple query parameters', () => { + const template = new UriTemplate('/search{?q,page,limit}'); + expect( + template.expand({ + q: 'test', + page: '1', + limit: '10' + }) + ).toBe('/search?q=test&page=1&limit=10'); + expect(template.variableNames).toEqual(['q', 'page', 'limit']); + }); + }); + + describe('matching complex patterns', () => { + it('should match nested path segments', () => { + const template = new UriTemplate('/api/{version}/{resource}/{id}'); + const match = template.match('/api/v1/users/123'); + expect(match).toEqual({ + version: 'v1', + resource: 'users', + id: '123' + }); + expect(template.variableNames).toEqual(['version', 'resource', 'id']); + }); + + it('should match query parameters', () => { + const template = new UriTemplate('/search{?q}'); + const match = template.match('/search?q=test'); + expect(match).toEqual({ q: 'test' }); + expect(template.variableNames).toEqual(['q']); + }); + + it('should match multiple query parameters', () => { + const template = new UriTemplate('/search{?q,page}'); + const match = template.match('/search?q=test&page=1'); + expect(match).toEqual({ q: 'test', page: '1' }); + expect(template.variableNames).toEqual(['q', 'page']); + }); + + it('should handle partial matches correctly', () => { + const template = new UriTemplate('/users/{id}'); + expect(template.match('/users/123/extra')).toBeNull(); + expect(template.match('/users')).toBeNull(); + }); + }); + + describe('security and edge cases', () => { + it('should handle extremely long input strings', () => { + const longString = 'x'.repeat(100_000); + const template = new UriTemplate(`/api/{param}`); + expect(template.expand({ param: longString })).toBe(`/api/${longString}`); + expect(template.match(`/api/${longString}`)).toEqual({ param: longString }); + }); + + it('should handle deeply nested template expressions', () => { + const template = new UriTemplate('{a}{b}{c}{d}{e}{f}{g}{h}{i}{j}'.repeat(1000)); + expect(() => + template.expand({ + a: '1', + b: '2', + c: '3', + d: '4', + e: '5', + f: '6', + g: '7', + h: '8', + i: '9', + j: '0' + }) + ).not.toThrow(); + }); + + it('should handle malformed template expressions', () => { + expect(() => new UriTemplate('{unclosed')).toThrow(); + expect(() => new UriTemplate('{}')).not.toThrow(); + expect(() => new UriTemplate('{,}')).not.toThrow(); + expect(() => new UriTemplate('{a}{')).toThrow(); + }); + + it('should handle pathological regex patterns', () => { + const template = new UriTemplate('/api/{param}'); + // Create a string that could cause catastrophic backtracking + const input = '/api/' + 'a'.repeat(100_000); + expect(() => template.match(input)).not.toThrow(); + }); + + it('should handle invalid UTF-8 sequences', () => { + const template = new UriTemplate('/api/{param}'); + const invalidUtf8 = '���'; + expect(() => template.expand({ param: invalidUtf8 })).not.toThrow(); + expect(() => template.match(`/api/${invalidUtf8}`)).not.toThrow(); + }); + + it('should handle template/URI length mismatches', () => { + const template = new UriTemplate('/api/{param}'); + expect(template.match('/api/')).toBeNull(); + expect(template.match('/api')).toBeNull(); + expect(template.match('/api/value/extra')).toBeNull(); + }); + + it('should handle repeated operators', () => { + const template = new UriTemplate('{?a}{?b}{?c}'); + expect(template.expand({ a: '1', b: '2', c: '3' })).toBe('?a=1&b=2&c=3'); + expect(template.variableNames).toEqual(['a', 'b', 'c']); + }); + + it('should handle overlapping variable names', () => { + const template = new UriTemplate('{var}{vara}'); + expect(template.expand({ var: '1', vara: '2' })).toBe('12'); + expect(template.variableNames).toEqual(['var', 'vara']); + }); + + it('should handle empty segments', () => { + const template = new UriTemplate('///{a}////{b}////'); + expect(template.expand({ a: '1', b: '2' })).toBe('///1////2////'); + expect(template.match('///1////2////')).toEqual({ a: '1', b: '2' }); + expect(template.variableNames).toEqual(['a', 'b']); + }); + + it('should handle maximum template expression limit', () => { + // Create a template with many expressions + const expressions = Array.from({ length: 10_000 }).fill('{param}').join(''); + expect(() => new UriTemplate(expressions)).not.toThrow(); + }); + + it('should handle maximum variable name length', () => { + const longName = 'a'.repeat(10_000); + const template = new UriTemplate(`{${longName}}`); + const vars: Record = { [longName]: 'value' }; + expect(() => template.expand(vars)).not.toThrow(); + }); + + it('should not be vulnerable to ReDoS with exploded path patterns', () => { + // Test for ReDoS vulnerability (CVE-2026-0621) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/965 + const template = new UriTemplate('{/id*}'); + const maliciousPayload = '/' + ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); + + it('should not be vulnerable to ReDoS with exploded simple patterns', () => { + // Test for ReDoS vulnerability with simple exploded operator + const template = new UriTemplate('{id*}'); + const maliciousPayload = ','.repeat(50); + + const startTime = Date.now(); + template.match(maliciousPayload); + const elapsed = Date.now() - startTime; + + // Should complete in under 100ms, not hang for seconds + expect(elapsed).toBeLessThan(100); + }); + }); +}); diff --git a/packages/core/test/shared/wrapHandler.test.ts b/packages/core/test/shared/wrapHandler.test.ts new file mode 100644 index 0000000..6a6e33f --- /dev/null +++ b/packages/core/test/shared/wrapHandler.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { Protocol } from '../../src/shared/protocol.js'; +import type { BaseContext, JSONRPCRequest, Result } from '../../src/exports/public/index.js'; + +class TestProtocol extends Protocol { + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} +} + +describe('Protocol._wrapHandler', () => { + it('routes setRequestHandler registration through _wrapHandler', () => { + const seen: string[] = []; + class SpyProtocol extends TestProtocol { + protected override _wrapHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise + ): (request: JSONRPCRequest, ctx: BaseContext) => Promise { + seen.push(method); + return handler; + } + } + const p = new SpyProtocol(); + seen.length = 0; + p.setRequestHandler('tools/list', () => ({ tools: [] })); + p.setRequestHandler('resources/list', () => ({ resources: [] })); + expect(seen).toEqual(['tools/list', 'resources/list']); + }); +}); diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts new file mode 100644 index 0000000..d26a4cd --- /dev/null +++ b/packages/core/test/spec.types.test.ts @@ -0,0 +1,1123 @@ +/** + * This contains: + * - Static type checks to verify the Spec's types are compatible with the SDK's types + * (mutually assignable — no type-level workarounds should be needed) + * - Runtime checks to verify each Spec type has a static check + * (note: a few don't have SDK types, see MISSING_SDK_TYPES below) + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import type * as SpecTypes from '../src/types/spec.types.js'; +import type * as SDKTypes from '../src/types/index.js'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Adds the `jsonrpc` property to a type, to match the on-wire format of notifications. +type WithJSONRPC = T & { jsonrpc: '2.0' }; + +// Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. +type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; + +// The spec defines typed *ResultResponse interfaces (e.g. InitializeResultResponse) that pair a +// JSONRPCResultResponse envelope with a specific result type. The SDK doesn't export these because +// nothing in the SDK needs the combined type — Protocol._onresponse() unwraps the envelope and +// validates the inner result separately. We define this locally to verify the composition still +// type-checks against the spec without polluting the SDK's public API. +type TypedResultResponse = SDKTypes.JSONRPCResultResponse & { result: R }; + +const sdkTypeChecks = { + RequestParams: (sdk: SDKTypes.RequestParams, spec: SpecTypes.RequestParams) => { + sdk = spec; + spec = sdk; + }, + NotificationParams: (sdk: SDKTypes.NotificationParams, spec: SpecTypes.NotificationParams) => { + sdk = spec; + spec = sdk; + }, + CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { + sdk = spec; + spec = sdk; + }, + InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { + sdk = spec; + spec = sdk; + }, + ProgressNotificationParams: (sdk: SDKTypes.ProgressNotificationParams, spec: SpecTypes.ProgressNotificationParams) => { + sdk = spec; + spec = sdk; + }, + ResourceRequestParams: (sdk: SDKTypes.ResourceRequestParams, spec: SpecTypes.ResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequestParams: (sdk: SDKTypes.ReadResourceRequestParams, spec: SpecTypes.ReadResourceRequestParams) => { + sdk = spec; + spec = sdk; + }, + SubscribeRequestParams: (sdk: SDKTypes.SubscribeRequestParams, spec: SpecTypes.SubscribeRequestParams) => { + sdk = spec; + spec = sdk; + }, + UnsubscribeRequestParams: (sdk: SDKTypes.UnsubscribeRequestParams, spec: SpecTypes.UnsubscribeRequestParams) => { + sdk = spec; + spec = sdk; + }, + ResourceUpdatedNotificationParams: ( + sdk: SDKTypes.ResourceUpdatedNotificationParams, + spec: SpecTypes.ResourceUpdatedNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequestParams: (sdk: SDKTypes.GetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + sdk = spec; + spec = sdk; + }, + CallToolRequestParams: (sdk: SDKTypes.CallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { + sdk = spec; + spec = sdk; + }, + SetLevelRequestParams: (sdk: SDKTypes.SetLevelRequestParams, spec: SpecTypes.SetLevelRequestParams) => { + sdk = spec; + spec = sdk; + }, + LoggingMessageNotificationParams: ( + sdk: SDKTypes.LoggingMessageNotificationParams, + spec: SpecTypes.LoggingMessageNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + sdk = spec; + spec = sdk; + }, + CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestFormParams: (sdk: SDKTypes.ElicitRequestFormParams, spec: SpecTypes.ElicitRequestFormParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitationCompleteNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.ElicitationCompleteNotification + ) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + sdk = spec; + spec = sdk; + }, + CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + sdk = spec; + spec = sdk; + }, + BaseMetadata: (sdk: SDKTypes.BaseMetadata, spec: SpecTypes.BaseMetadata) => { + sdk = spec; + spec = sdk; + }, + Implementation: (sdk: SDKTypes.Implementation, spec: SpecTypes.Implementation) => { + sdk = spec; + spec = sdk; + }, + ProgressNotification: (sdk: WithJSONRPC, spec: SpecTypes.ProgressNotification) => { + sdk = spec; + spec = sdk; + }, + SubscribeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SubscribeRequest) => { + sdk = spec; + spec = sdk; + }, + UnsubscribeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.UnsubscribeRequest) => { + sdk = spec; + spec = sdk; + }, + PaginatedRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.PaginatedRequest) => { + sdk = spec; + spec = sdk; + }, + PaginatedResult: (sdk: SDKTypes.PaginatedResult, spec: SpecTypes.PaginatedResult) => { + sdk = spec; + spec = sdk; + }, + ListRootsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListRootsRequest) => { + sdk = spec; + spec = sdk; + }, + ListRootsResult: (sdk: SDKTypes.ListRootsResult, spec: SpecTypes.ListRootsResult) => { + sdk = spec; + spec = sdk; + }, + Root: (sdk: SDKTypes.Root, spec: SpecTypes.Root) => { + sdk = spec; + spec = sdk; + }, + ElicitRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ElicitRequest) => { + sdk = spec; + spec = sdk; + }, + ElicitResult: (sdk: SDKTypes.ElicitResult, spec: SpecTypes.ElicitResult) => { + sdk = spec; + spec = sdk; + }, + CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + sdk = spec; + spec = sdk; + }, + CompleteResult: (sdk: SDKTypes.CompleteResult, spec: SpecTypes.CompleteResult) => { + sdk = spec; + spec = sdk; + }, + ProgressToken: (sdk: SDKTypes.ProgressToken, spec: SpecTypes.ProgressToken) => { + sdk = spec; + spec = sdk; + }, + Cursor: (sdk: SDKTypes.Cursor, spec: SpecTypes.Cursor) => { + sdk = spec; + spec = sdk; + }, + Request: (sdk: SDKTypes.Request, spec: SpecTypes.Request) => { + sdk = spec; + spec = sdk; + }, + Result: (sdk: SDKTypes.Result, spec: SpecTypes.Result) => { + sdk = spec; + spec = sdk; + }, + RequestId: (sdk: SDKTypes.RequestId, spec: SpecTypes.RequestId) => { + sdk = spec; + spec = sdk; + }, + JSONRPCRequest: (sdk: SDKTypes.JSONRPCRequest, spec: SpecTypes.JSONRPCRequest) => { + sdk = spec; + spec = sdk; + }, + JSONRPCNotification: (sdk: SDKTypes.JSONRPCNotification, spec: SpecTypes.JSONRPCNotification) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResponse: (sdk: SDKTypes.JSONRPCResponse, spec: SpecTypes.JSONRPCResponse) => { + sdk = spec; + spec = sdk; + }, + EmptyResult: (sdk: SDKTypes.EmptyResult, spec: SpecTypes.EmptyResult) => { + sdk = spec; + spec = sdk; + }, + Notification: (sdk: SDKTypes.Notification, spec: SpecTypes.Notification) => { + sdk = spec; + spec = sdk; + }, + ClientResult: (sdk: SDKTypes.ClientResult, spec: SpecTypes.ClientResult) => { + sdk = spec; + spec = sdk; + }, + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + sdk = spec; + spec = sdk; + }, + ServerResult: (sdk: SDKTypes.ServerResult, spec: SpecTypes.ServerResult) => { + sdk = spec; + spec = sdk; + }, + ResourceTemplateReference: (sdk: SDKTypes.ResourceTemplateReference, spec: SpecTypes.ResourceTemplateReference) => { + sdk = spec; + spec = sdk; + }, + PromptReference: (sdk: SDKTypes.PromptReference, spec: SpecTypes.PromptReference) => { + sdk = spec; + spec = sdk; + }, + ToolAnnotations: (sdk: SDKTypes.ToolAnnotations, spec: SpecTypes.ToolAnnotations) => { + sdk = spec; + spec = sdk; + }, + Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { + sdk = spec; + spec = sdk; + }, + ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { + sdk = spec; + spec = sdk; + }, + ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { + sdk = spec; + spec = sdk; + }, + CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { + sdk = spec; + spec = sdk; + }, + CallToolRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CallToolRequest) => { + sdk = spec; + spec = sdk; + }, + ToolListChangedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ToolListChangedNotification) => { + sdk = spec; + spec = sdk; + }, + ResourceListChangedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.ResourceListChangedNotification + ) => { + sdk = spec; + spec = sdk; + }, + PromptListChangedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.PromptListChangedNotification + ) => { + sdk = spec; + spec = sdk; + }, + RootsListChangedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.RootsListChangedNotification + ) => { + sdk = spec; + spec = sdk; + }, + ResourceUpdatedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ResourceUpdatedNotification) => { + sdk = spec; + spec = sdk; + }, + SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { + sdk = spec; + spec = sdk; + }, + CreateMessageResult: (sdk: SDKTypes.CreateMessageResultWithTools, spec: SpecTypes.CreateMessageResult) => { + sdk = spec; + spec = sdk; + }, + SetLevelRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.SetLevelRequest) => { + sdk = spec; + spec = sdk; + }, + PingRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.PingRequest) => { + sdk = spec; + spec = sdk; + }, + InitializedNotification: (sdk: WithJSONRPC, spec: SpecTypes.InitializedNotification) => { + sdk = spec; + spec = sdk; + }, + ListResourcesRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListResourcesRequest) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResult: (sdk: SDKTypes.ListResourcesResult, spec: SpecTypes.ListResourcesResult) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesRequest: ( + sdk: WithJSONRPCRequest, + spec: SpecTypes.ListResourceTemplatesRequest + ) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResult: (sdk: SDKTypes.ListResourceTemplatesResult, spec: SpecTypes.ListResourceTemplatesResult) => { + sdk = spec; + spec = sdk; + }, + ReadResourceRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ReadResourceRequest) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResult: (sdk: SDKTypes.ReadResourceResult, spec: SpecTypes.ReadResourceResult) => { + sdk = spec; + spec = sdk; + }, + ResourceContents: (sdk: SDKTypes.ResourceContents, spec: SpecTypes.ResourceContents) => { + sdk = spec; + spec = sdk; + }, + TextResourceContents: (sdk: SDKTypes.TextResourceContents, spec: SpecTypes.TextResourceContents) => { + sdk = spec; + spec = sdk; + }, + BlobResourceContents: (sdk: SDKTypes.BlobResourceContents, spec: SpecTypes.BlobResourceContents) => { + sdk = spec; + spec = sdk; + }, + Resource: (sdk: SDKTypes.Resource, spec: SpecTypes.Resource) => { + sdk = spec; + spec = sdk; + }, + ResourceTemplate: (sdk: SDKTypes.ResourceTemplateType, spec: SpecTypes.ResourceTemplate) => { + sdk = spec; + spec = sdk; + }, + PromptArgument: (sdk: SDKTypes.PromptArgument, spec: SpecTypes.PromptArgument) => { + sdk = spec; + spec = sdk; + }, + Prompt: (sdk: SDKTypes.Prompt, spec: SpecTypes.Prompt) => { + sdk = spec; + spec = sdk; + }, + ListPromptsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListPromptsRequest) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResult: (sdk: SDKTypes.ListPromptsResult, spec: SpecTypes.ListPromptsResult) => { + sdk = spec; + spec = sdk; + }, + GetPromptRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetPromptRequest) => { + sdk = spec; + spec = sdk; + }, + TextContent: (sdk: SDKTypes.TextContent, spec: SpecTypes.TextContent) => { + sdk = spec; + spec = sdk; + }, + ImageContent: (sdk: SDKTypes.ImageContent, spec: SpecTypes.ImageContent) => { + sdk = spec; + spec = sdk; + }, + AudioContent: (sdk: SDKTypes.AudioContent, spec: SpecTypes.AudioContent) => { + sdk = spec; + spec = sdk; + }, + EmbeddedResource: (sdk: SDKTypes.EmbeddedResource, spec: SpecTypes.EmbeddedResource) => { + sdk = spec; + spec = sdk; + }, + ResourceLink: (sdk: SDKTypes.ResourceLink, spec: SpecTypes.ResourceLink) => { + sdk = spec; + spec = sdk; + }, + ContentBlock: (sdk: SDKTypes.ContentBlock, spec: SpecTypes.ContentBlock) => { + sdk = spec; + spec = sdk; + }, + PromptMessage: (sdk: SDKTypes.PromptMessage, spec: SpecTypes.PromptMessage) => { + sdk = spec; + spec = sdk; + }, + GetPromptResult: (sdk: SDKTypes.GetPromptResult, spec: SpecTypes.GetPromptResult) => { + sdk = spec; + spec = sdk; + }, + BooleanSchema: (sdk: SDKTypes.BooleanSchema, spec: SpecTypes.BooleanSchema) => { + sdk = spec; + spec = sdk; + }, + StringSchema: (sdk: SDKTypes.StringSchema, spec: SpecTypes.StringSchema) => { + sdk = spec; + spec = sdk; + }, + NumberSchema: (sdk: SDKTypes.NumberSchema, spec: SpecTypes.NumberSchema) => { + sdk = spec; + spec = sdk; + }, + EnumSchema: (sdk: SDKTypes.EnumSchema, spec: SpecTypes.EnumSchema) => { + sdk = spec; + spec = sdk; + }, + UntitledSingleSelectEnumSchema: (sdk: SDKTypes.UntitledSingleSelectEnumSchema, spec: SpecTypes.UntitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledSingleSelectEnumSchema: (sdk: SDKTypes.TitledSingleSelectEnumSchema, spec: SpecTypes.TitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + SingleSelectEnumSchema: (sdk: SDKTypes.SingleSelectEnumSchema, spec: SpecTypes.SingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + UntitledMultiSelectEnumSchema: (sdk: SDKTypes.UntitledMultiSelectEnumSchema, spec: SpecTypes.UntitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledMultiSelectEnumSchema: (sdk: SDKTypes.TitledMultiSelectEnumSchema, spec: SpecTypes.TitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + MultiSelectEnumSchema: (sdk: SDKTypes.MultiSelectEnumSchema, spec: SpecTypes.MultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + LegacyTitledEnumSchema: (sdk: SDKTypes.LegacyTitledEnumSchema, spec: SpecTypes.LegacyTitledEnumSchema) => { + sdk = spec; + spec = sdk; + }, + PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { + sdk = spec; + spec = sdk; + }, + JSONRPCErrorResponse: (sdk: SDKTypes.JSONRPCErrorResponse, spec: SpecTypes.JSONRPCErrorResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCResultResponse: (sdk: SDKTypes.JSONRPCResultResponse, spec: SpecTypes.JSONRPCResultResponse) => { + sdk = spec; + spec = sdk; + }, + JSONRPCMessage: (sdk: SDKTypes.JSONRPCMessage, spec: SpecTypes.JSONRPCMessage) => { + sdk = spec; + spec = sdk; + }, + CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { + sdk = spec; + spec = sdk; + }, + InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { + sdk = spec; + spec = sdk; + }, + InitializeResult: (sdk: SDKTypes.InitializeResult, spec: SpecTypes.InitializeResult) => { + sdk = spec; + spec = sdk; + }, + ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { + sdk = spec; + spec = sdk; + }, + ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { + sdk = spec; + spec = sdk; + }, + ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + sdk = spec; + spec = sdk; + }, + ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + sdk = spec; + spec = sdk; + }, + LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { + sdk = spec; + spec = sdk; + }, + ServerNotification: (sdk: WithJSONRPC, spec: SpecTypes.ServerNotification) => { + sdk = spec; + spec = sdk; + }, + LoggingLevel: (sdk: SDKTypes.LoggingLevel, spec: SpecTypes.LoggingLevel) => { + sdk = spec; + spec = sdk; + }, + Icon: (sdk: SDKTypes.Icon, spec: SpecTypes.Icon) => { + sdk = spec; + spec = sdk; + }, + Icons: (sdk: SDKTypes.Icons, spec: SpecTypes.Icons) => { + sdk = spec; + spec = sdk; + }, + ModelHint: (sdk: SDKTypes.ModelHint, spec: SpecTypes.ModelHint) => { + sdk = spec; + spec = sdk; + }, + ModelPreferences: (sdk: SDKTypes.ModelPreferences, spec: SpecTypes.ModelPreferences) => { + sdk = spec; + spec = sdk; + }, + ToolChoice: (sdk: SDKTypes.ToolChoice, spec: SpecTypes.ToolChoice) => { + sdk = spec; + spec = sdk; + }, + ToolUseContent: (sdk: SDKTypes.ToolUseContent, spec: SpecTypes.ToolUseContent) => { + sdk = spec; + spec = sdk; + }, + ToolResultContent: (sdk: SDKTypes.ToolResultContent, spec: SpecTypes.ToolResultContent) => { + sdk = spec; + spec = sdk; + }, + SamplingMessageContentBlock: (sdk: SDKTypes.SamplingMessageContentBlock, spec: SpecTypes.SamplingMessageContentBlock) => { + sdk = spec; + spec = sdk; + }, + Annotations: (sdk: SDKTypes.Annotations, spec: SpecTypes.Annotations) => { + sdk = spec; + spec = sdk; + }, + Role: (sdk: SDKTypes.Role, spec: SpecTypes.Role) => { + sdk = spec; + spec = sdk; + }, + TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { + sdk = spec; + spec = sdk; + }, + ToolExecution: (sdk: SDKTypes.ToolExecution, spec: SpecTypes.ToolExecution) => { + sdk = spec; + spec = sdk; + }, + TaskStatus: (sdk: SDKTypes.TaskStatus, spec: SpecTypes.TaskStatus) => { + sdk = spec; + spec = sdk; + }, + TaskMetadata: (sdk: SDKTypes.TaskMetadata, spec: SpecTypes.TaskMetadata) => { + sdk = spec; + spec = sdk; + }, + RelatedTaskMetadata: (sdk: SDKTypes.RelatedTaskMetadata, spec: SpecTypes.RelatedTaskMetadata) => { + sdk = spec; + spec = sdk; + }, + Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { + sdk = spec; + spec = sdk; + }, + CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { + sdk = spec; + spec = sdk; + }, + GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { + sdk = spec; + spec = sdk; + }, + GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { + sdk = spec; + spec = sdk; + }, + ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { + sdk = spec; + spec = sdk; + }, + ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { + sdk = spec; + spec = sdk; + }, + CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { + sdk = spec; + spec = sdk; + }, + CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { + sdk = spec; + spec = sdk; + }, + GetTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskRequest) => { + sdk = spec; + spec = sdk; + }, + GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { + sdk = spec; + spec = sdk; + }, + TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { + sdk = spec; + spec = sdk; + }, + TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { + sdk = spec; + spec = sdk; + }, + + /* JSON primitives */ + JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { + sdk = spec; + spec = sdk; + }, + JSONObject: (sdk: SDKTypes.JSONObject, spec: SpecTypes.JSONObject) => { + sdk = spec; + spec = sdk; + }, + JSONArray: (sdk: SDKTypes.JSONArray, spec: SpecTypes.JSONArray) => { + sdk = spec; + spec = sdk; + }, + + /* Meta types */ + MetaObject: (sdk: SDKTypes.MetaObject, spec: SpecTypes.MetaObject) => { + sdk = spec; + spec = sdk; + }, + RequestMetaObject: (sdk: SDKTypes.RequestMetaObject, spec: SpecTypes.RequestMetaObject) => { + sdk = spec; + spec = sdk; + }, + + /* Error types */ + ParseError: (sdk: SDKTypes.ParseError, spec: SpecTypes.ParseError) => { + sdk = spec; + spec = sdk; + }, + InvalidRequestError: (sdk: SDKTypes.InvalidRequestError, spec: SpecTypes.InvalidRequestError) => { + sdk = spec; + spec = sdk; + }, + MethodNotFoundError: (sdk: SDKTypes.MethodNotFoundError, spec: SpecTypes.MethodNotFoundError) => { + sdk = spec; + spec = sdk; + }, + InvalidParamsError: (sdk: SDKTypes.InvalidParamsError, spec: SpecTypes.InvalidParamsError) => { + sdk = spec; + spec = sdk; + }, + InternalError: (sdk: SDKTypes.InternalError, spec: SpecTypes.InternalError) => { + sdk = spec; + spec = sdk; + }, + + /* ResultResponse types — see TypedResultResponse comment above */ + InitializeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.InitializeResultResponse) => { + sdk = spec; + spec = sdk; + }, + PingResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.PingResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListResourcesResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListResourcesResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListResourceTemplatesResultResponse: ( + sdk: TypedResultResponse, + spec: SpecTypes.ListResourceTemplatesResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + ReadResourceResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { + sdk = spec; + spec = sdk; + }, + SubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SubscribeResultResponse) => { + sdk = spec; + spec = sdk; + }, + UnsubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.UnsubscribeResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListPromptsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListPromptsResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetPromptResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetPromptResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListToolsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListToolsResultResponse) => { + sdk = spec; + spec = sdk; + }, + CallToolResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CallToolResultResponse) => { + sdk = spec; + spec = sdk; + }, + CreateTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CreateTaskResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetTaskResultResponse) => { + sdk = spec; + spec = sdk; + }, + GetTaskPayloadResultResponse: ( + sdk: TypedResultResponse, + spec: SpecTypes.GetTaskPayloadResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + CancelTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CancelTaskResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListTasksResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListTasksResultResponse) => { + sdk = spec; + spec = sdk; + }, + SetLevelResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SetLevelResultResponse) => { + sdk = spec; + spec = sdk; + }, + CreateMessageResultResponse: ( + sdk: TypedResultResponse, + spec: SpecTypes.CreateMessageResultResponse + ) => { + sdk = spec; + spec = sdk; + }, + CompleteResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CompleteResultResponse) => { + sdk = spec; + spec = sdk; + }, + ListRootsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListRootsResultResponse) => { + sdk = spec; + spec = sdk; + }, + ElicitResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ElicitResultResponse) => { + sdk = spec; + spec = sdk; + } +}; + +// --------------------------------------------------------------------------- +// Key-level assertions: verify that each SDK type and its corresponding spec +// type expose exactly the same set of named property keys. This catches cases +// where a Zod schema marks a field as `.optional()` but the spec does not (or +// vice-versa), which the mutual-assignability checks above cannot detect +// because optional fields satisfy structural subtyping in both directions. +// --------------------------------------------------------------------------- + +/** Strip index signatures, keeping only explicitly-named keys. */ +type KnownKeys = keyof { + [K in keyof T as string extends K ? never : number extends K ? never : symbol extends K ? never : K]: T[K]; +}; + +/** + * Assert that A and B have exactly the same set of known (named) keys. + * Resolves to `true` on match; a descriptive error type on mismatch. + */ +type AssertExactKeys< + A, + B, + Extra extends PropertyKey = Exclude, KnownKeys>, + Missing extends PropertyKey = Exclude, KnownKeys> +> = [Extra, Missing] extends [never, never] ? true : { _brand: 'KeyMismatch'; extra: Extra; missing: Missing }; + +/** Constraint: T must resolve to `true`. */ +type Assert = T; + +/* + * Excluded from key-level assertions (23 entries): + * + * Union types — KnownKeys cannot meaningfully enumerate their members (15): + * ClientRequest, ServerRequest, ClientNotification, ServerNotification, + * ClientResult, ServerResult, JSONRPCMessage, JSONRPCResponse, ContentBlock, + * SamplingMessageContentBlock, ElicitRequestParams, PrimitiveSchemaDefinition, + * SingleSelectEnumSchema, MultiSelectEnumSchema, EnumSchema + * + * Primitive type aliases — no object keys to compare (8): + * JSONValue, JSONArray, Role, LoggingLevel, ProgressToken, RequestId, + * Cursor, TaskStatus + */ + +// -- Simple types (96) -- + +type _K_RequestParams = Assert>; +type _K_NotificationParams = Assert>; +type _K_CancelledNotificationParams = Assert>; +type _K_InitializeRequestParams = Assert>; +type _K_ProgressNotificationParams = Assert>; +type _K_ResourceRequestParams = Assert>; +type _K_ReadResourceRequestParams = Assert>; +type _K_SubscribeRequestParams = Assert>; +type _K_UnsubscribeRequestParams = Assert>; +type _K_ResourceUpdatedNotificationParams = Assert< + AssertExactKeys +>; +type _K_GetPromptRequestParams = Assert>; +type _K_CallToolRequestParams = Assert>; +type _K_SetLevelRequestParams = Assert>; +type _K_LoggingMessageNotificationParams = Assert< + AssertExactKeys +>; +type _K_CreateMessageRequestParams = Assert>; +type _K_CompleteRequestParams = Assert>; +type _K_ElicitRequestFormParams = Assert>; +type _K_ElicitRequestURLParams = Assert>; +type _K_PaginatedRequestParams = Assert>; +type _K_BaseMetadata = Assert>; +type _K_Implementation = Assert>; +type _K_PaginatedResult = Assert>; +type _K_ListRootsResult = Assert>; +type _K_Root = Assert>; +type _K_ElicitResult = Assert>; +type _K_CompleteResult = Assert>; +type _K_Request = Assert>; +type _K_Result = Assert>; +type _K_JSONRPCRequest = Assert>; +type _K_JSONRPCNotification = Assert>; +type _K_EmptyResult = Assert>; +type _K_Notification = Assert>; +type _K_ResourceTemplateReference = Assert>; +// @ts-expect-error Genuine mismatch: SDK PromptReference is missing 'title' from spec +type _K_PromptReference = Assert>; +type _K_ToolAnnotations = Assert>; +type _K_Tool = Assert>; +type _K_ListToolsResult = Assert>; +type _K_CallToolResult = Assert>; +type _K_ListResourcesResult = Assert>; +type _K_ListResourceTemplatesResult = Assert>; +type _K_ReadResourceResult = Assert>; +type _K_ResourceContents = Assert>; +type _K_TextResourceContents = Assert>; +type _K_BlobResourceContents = Assert>; +type _K_Resource = Assert>; +// @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec +type _K_PromptArgument = Assert>; +type _K_Prompt = Assert>; +type _K_ListPromptsResult = Assert>; +type _K_GetPromptResult = Assert>; +type _K_TextContent = Assert>; +type _K_ImageContent = Assert>; +type _K_AudioContent = Assert>; +type _K_EmbeddedResource = Assert>; +type _K_ResourceLink = Assert>; +type _K_PromptMessage = Assert>; +type _K_BooleanSchema = Assert>; +type _K_StringSchema = Assert>; +type _K_NumberSchema = Assert>; +type _K_UntitledSingleSelectEnumSchema = Assert< + AssertExactKeys +>; +type _K_TitledSingleSelectEnumSchema = Assert< + AssertExactKeys +>; +type _K_UntitledMultiSelectEnumSchema = Assert< + AssertExactKeys +>; +type _K_TitledMultiSelectEnumSchema = Assert>; +type _K_LegacyTitledEnumSchema = Assert>; +type _K_JSONRPCErrorResponse = Assert>; +type _K_JSONRPCResultResponse = Assert>; +type _K_InitializeResult = Assert>; +type _K_ClientCapabilities = Assert>; +type _K_ServerCapabilities = Assert>; +type _K_SamplingMessage = Assert>; +type _K_Icon = Assert>; +type _K_Icons = Assert>; +type _K_ModelHint = Assert>; +type _K_ModelPreferences = Assert>; +type _K_ToolChoice = Assert>; +type _K_ToolUseContent = Assert>; +type _K_ToolResultContent = Assert>; +type _K_Annotations = Assert>; +type _K_TaskAugmentedRequestParams = Assert>; +type _K_ToolExecution = Assert>; +type _K_TaskMetadata = Assert>; +type _K_RelatedTaskMetadata = Assert>; +type _K_Task = Assert>; +type _K_CreateTaskResult = Assert>; +type _K_GetTaskResult = Assert>; +type _K_ListTasksResult = Assert>; +type _K_CancelTaskResult = Assert>; +type _K_GetTaskPayloadResult = Assert>; +type _K_TaskStatusNotificationParams = Assert< + AssertExactKeys +>; +type _K_JSONObject = Assert>; +type _K_MetaObject = Assert>; +// @ts-expect-error Genuine mismatch: SDK RequestMetaObject has extra 'io.modelcontextprotocol/related-task' not in spec +type _K_RequestMetaObject = Assert>; +type _K_ParseError = Assert>; +type _K_InvalidRequestError = Assert>; +type _K_MethodNotFoundError = Assert>; +type _K_InvalidParamsError = Assert>; +type _K_InternalError = Assert>; + +// -- WithJSONRPC-wrapped notification types (11) -- +// SDK notification types do not include `jsonrpc` — the spec types do. We wrap +// with WithJSONRPC<> to add the missing field before comparing keys. + +type _K_ElicitationCompleteNotification = Assert< + AssertExactKeys, SpecTypes.ElicitationCompleteNotification> +>; +type _K_CancelledNotification = Assert, SpecTypes.CancelledNotification>>; +type _K_ProgressNotification = Assert, SpecTypes.ProgressNotification>>; +type _K_ToolListChangedNotification = Assert< + AssertExactKeys, SpecTypes.ToolListChangedNotification> +>; +type _K_ResourceListChangedNotification = Assert< + AssertExactKeys, SpecTypes.ResourceListChangedNotification> +>; +type _K_PromptListChangedNotification = Assert< + AssertExactKeys, SpecTypes.PromptListChangedNotification> +>; +type _K_RootsListChangedNotification = Assert< + AssertExactKeys, SpecTypes.RootsListChangedNotification> +>; +type _K_ResourceUpdatedNotification = Assert< + AssertExactKeys, SpecTypes.ResourceUpdatedNotification> +>; +type _K_LoggingMessageNotification = Assert< + AssertExactKeys, SpecTypes.LoggingMessageNotification> +>; +type _K_InitializedNotification = Assert, SpecTypes.InitializedNotification>>; +type _K_TaskStatusNotification = Assert, SpecTypes.TaskStatusNotification>>; + +// -- WithJSONRPCRequest-wrapped request types (21) -- +// SDK request types do not include `jsonrpc` or `id` — the spec types do. We +// wrap with WithJSONRPCRequest<> to add the missing fields before comparing keys. + +type _K_SubscribeRequest = Assert, SpecTypes.SubscribeRequest>>; +type _K_UnsubscribeRequest = Assert, SpecTypes.UnsubscribeRequest>>; +type _K_PaginatedRequest = Assert, SpecTypes.PaginatedRequest>>; +type _K_ListRootsRequest = Assert, SpecTypes.ListRootsRequest>>; +type _K_ElicitRequest = Assert, SpecTypes.ElicitRequest>>; +type _K_CompleteRequest = Assert, SpecTypes.CompleteRequest>>; +type _K_ListToolsRequest = Assert, SpecTypes.ListToolsRequest>>; +type _K_CallToolRequest = Assert, SpecTypes.CallToolRequest>>; +type _K_SetLevelRequest = Assert, SpecTypes.SetLevelRequest>>; +type _K_PingRequest = Assert, SpecTypes.PingRequest>>; +type _K_ListResourcesRequest = Assert, SpecTypes.ListResourcesRequest>>; +type _K_ListResourceTemplatesRequest = Assert< + AssertExactKeys, SpecTypes.ListResourceTemplatesRequest> +>; +type _K_ReadResourceRequest = Assert, SpecTypes.ReadResourceRequest>>; +type _K_ListPromptsRequest = Assert, SpecTypes.ListPromptsRequest>>; +type _K_GetPromptRequest = Assert, SpecTypes.GetPromptRequest>>; +type _K_CreateMessageRequest = Assert, SpecTypes.CreateMessageRequest>>; +type _K_InitializeRequest = Assert, SpecTypes.InitializeRequest>>; +type _K_GetTaskPayloadRequest = Assert< + AssertExactKeys, SpecTypes.GetTaskPayloadRequest> +>; +type _K_ListTasksRequest = Assert, SpecTypes.ListTasksRequest>>; +type _K_CancelTaskRequest = Assert, SpecTypes.CancelTaskRequest>>; +type _K_GetTaskRequest = Assert, SpecTypes.GetTaskRequest>>; + +// -- TypedResultResponse-wrapped types (21) -- +// The spec defines typed *ResultResponse interfaces that pair JSONRPCResultResponse +// with a specific result. We compare TypedResultResponse against the +// spec's combined type. + +type _K_InitializeResultResponse = Assert< + AssertExactKeys, SpecTypes.InitializeResultResponse> +>; +type _K_PingResultResponse = Assert, SpecTypes.PingResultResponse>>; +type _K_ListResourcesResultResponse = Assert< + AssertExactKeys, SpecTypes.ListResourcesResultResponse> +>; +type _K_ListResourceTemplatesResultResponse = Assert< + AssertExactKeys, SpecTypes.ListResourceTemplatesResultResponse> +>; +type _K_ReadResourceResultResponse = Assert< + AssertExactKeys, SpecTypes.ReadResourceResultResponse> +>; +type _K_SubscribeResultResponse = Assert, SpecTypes.SubscribeResultResponse>>; +type _K_UnsubscribeResultResponse = Assert, SpecTypes.UnsubscribeResultResponse>>; +type _K_ListPromptsResultResponse = Assert< + AssertExactKeys, SpecTypes.ListPromptsResultResponse> +>; +type _K_GetPromptResultResponse = Assert, SpecTypes.GetPromptResultResponse>>; +type _K_ListToolsResultResponse = Assert, SpecTypes.ListToolsResultResponse>>; +type _K_CallToolResultResponse = Assert, SpecTypes.CallToolResultResponse>>; +type _K_CreateTaskResultResponse = Assert< + AssertExactKeys, SpecTypes.CreateTaskResultResponse> +>; +type _K_GetTaskResultResponse = Assert, SpecTypes.GetTaskResultResponse>>; +type _K_GetTaskPayloadResultResponse = Assert< + AssertExactKeys, SpecTypes.GetTaskPayloadResultResponse> +>; +type _K_CancelTaskResultResponse = Assert< + AssertExactKeys, SpecTypes.CancelTaskResultResponse> +>; +type _K_ListTasksResultResponse = Assert, SpecTypes.ListTasksResultResponse>>; +type _K_SetLevelResultResponse = Assert, SpecTypes.SetLevelResultResponse>>; +type _K_CreateMessageResultResponse = Assert< + AssertExactKeys, SpecTypes.CreateMessageResultResponse> +>; +type _K_CompleteResultResponse = Assert, SpecTypes.CompleteResultResponse>>; +type _K_ListRootsResultResponse = Assert, SpecTypes.ListRootsResultResponse>>; +type _K_ElicitResultResponse = Assert, SpecTypes.ElicitResultResponse>>; + +// -- Name mismatches (2) -- +// SDK exports these under different names than the spec. + +type _K_CreateMessageResult = Assert>; +type _K_ResourceTemplate = Assert>; + +// Types excluded from the key-parity completeness guard: union types and primitive aliases +// that cannot have meaningful AssertExactKeys assertions. +const KEY_PARITY_EXCLUDED = [ + // Union types (15) + 'ClientRequest', + 'ServerRequest', + 'ClientNotification', + 'ServerNotification', + 'ClientResult', + 'ServerResult', + 'JSONRPCMessage', + 'JSONRPCResponse', + 'ContentBlock', + 'SamplingMessageContentBlock', + 'ElicitRequestParams', + 'PrimitiveSchemaDefinition', + 'SingleSelectEnumSchema', + 'MultiSelectEnumSchema', + 'EnumSchema', + // Primitive aliases (8) + 'JSONValue', + 'JSONArray', + 'Role', + 'LoggingLevel', + 'ProgressToken', + 'RequestId', + 'Cursor', + 'TaskStatus' +]; + +// This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) +const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.ts'); +const SDK_TYPES_FILE = path.resolve(__dirname, '../src/types/types.ts'); + +const MISSING_SDK_TYPES = [ + // These are inlined in the SDK: + 'Error', // The inner error object of a JSONRPCError + 'URLElicitationRequiredError' // In the SDK, but with a custom definition +]; + +function extractExportedTypes(source: string): string[] { + const matches = [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)]; + return matches.map(m => m[1]!); +} + +function extractKeyParityTypes(source: string): string[] { + return [...source.matchAll(/^type _K_(\w+)\s*=/gm)].map(m => m[1]!); +} + +describe('Spec Types', () => { + const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf8')); + const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf8')); + const typesToCheck = specTypes.filter(type => !MISSING_SDK_TYPES.includes(type)); + + it('should define some expected types', () => { + expect(specTypes).toContain('JSONRPCNotification'); + expect(specTypes).toContain('ElicitResult'); + expect(specTypes).toHaveLength(176); + }); + + it('should have up to date list of missing sdk types', () => { + for (const typeName of MISSING_SDK_TYPES) { + expect(sdkTypes).not.toContain(typeName); + } + }); + + it('should have comprehensive compatibility tests', () => { + const missingTests = []; + + for (const typeName of typesToCheck) { + if (!sdkTypeChecks[typeName as keyof typeof sdkTypeChecks]) { + missingTests.push(typeName); + } + } + + expect(missingTests).toHaveLength(0); + }); + + it('should have key-parity assertions for all non-excluded compatibility tests', () => { + const thisSource = fs.readFileSync(__filename, 'utf8'); + const checked = new Set(extractKeyParityTypes(thisSource)); + const excluded = new Set(KEY_PARITY_EXCLUDED); + const missing = Object.keys(sdkTypeChecks).filter(name => !checked.has(name) && !excluded.has(name)); + expect(missing).toHaveLength(0); + }); + + describe('Missing SDK Types', () => { + it.each(MISSING_SDK_TYPES)('%s should not be present in MISSING_SDK_TYPES if it has a compatibility test', type => { + expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/types.capabilities.test.ts b/packages/core/test/types.capabilities.test.ts new file mode 100644 index 0000000..1f66184 --- /dev/null +++ b/packages/core/test/types.capabilities.test.ts @@ -0,0 +1,103 @@ +import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from '../src/types/index.js'; + +describe('ClientCapabilitiesSchema backwards compatibility', () => { + describe('ElicitationCapabilitySchema preprocessing', () => { + it('should inject form capability when elicitation is an empty object', () => { + const capabilities = { + elicitation: {} + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should preserve form capability configuration including applyDefaults', () => { + const capabilities = { + elicitation: { + form: { + applyDefaults: true + } + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({ applyDefaults: true }); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when form is explicitly declared', () => { + const capabilities = { + elicitation: { + form: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when url is explicitly declared', () => { + const capabilities = { + elicitation: { + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.url).toEqual({}); + expect(result.elicitation?.form).toBeUndefined(); + }); + + it('should not inject form capability when both form and url are explicitly declared', () => { + const capabilities = { + elicitation: { + form: {}, + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toEqual({}); + }); + + it('should not inject form capability when elicitation is undefined', () => { + const capabilities = {}; + + const result = ClientCapabilitiesSchema.parse(capabilities); + // When elicitation is not provided, it should remain undefined + expect(result.elicitation).toBeUndefined(); + }); + + it('should work within InitializeRequestParamsSchema context', () => { + const initializeParams = { + protocolVersion: '2025-11-25', + capabilities: { + elicitation: {} + }, + clientInfo: { + name: 'test client', + version: '1.0' + } + }; + + const result = InitializeRequestParamsSchema.parse(initializeParams); + expect(result.capabilities.elicitation).toBeDefined(); + expect(result.capabilities.elicitation?.form).toBeDefined(); + expect(result.capabilities.elicitation?.form).toEqual({}); + }); + }); +}); diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts new file mode 100644 index 0000000..9383f7d --- /dev/null +++ b/packages/core/test/types.test.ts @@ -0,0 +1,1014 @@ +import { + CallToolResultSchema, + ClientCapabilitiesSchema, + CompleteRequestSchema, + ContentBlockSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + ElicitRequestFormParamsSchema, + LATEST_PROTOCOL_VERSION, + PromptMessageSchema, + ResourceLinkSchema, + SamplingMessageSchema, + SUPPORTED_PROTOCOL_VERSIONS, + ToolChoiceSchema, + ToolResultContentSchema, + ToolSchema, + ToolUseContentSchema +} from '../src/types/index.js'; + +describe('Types', () => { + test('should have correct latest protocol version', () => { + expect(LATEST_PROTOCOL_VERSION).toBeDefined(); + expect(LATEST_PROTOCOL_VERSION).toBe('2025-11-25'); + }); + test('should have correct supported protocol versions', () => { + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); + expect(SUPPORTED_PROTOCOL_VERSIONS).toBeInstanceOf(Array); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2025-06-18'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2025-03-26'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2024-11-05'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2024-10-07'); + }); + + describe('ResourceLink', () => { + test('should validate a minimal ResourceLink', () => { + const resourceLink = { + type: 'resource_link', + uri: 'file:///path/to/file.txt', + name: 'file.txt' + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('resource_link'); + expect(result.data.uri).toBe('file:///path/to/file.txt'); + expect(result.data.name).toBe('file.txt'); + } + }); + + test('should validate a ResourceLink with all optional fields', () => { + const resourceLink = { + type: 'resource_link', + uri: 'https://example.com/resource', + name: 'Example Resource', + title: 'A comprehensive example resource', + description: 'This resource demonstrates all fields', + mimeType: 'text/plain', + _meta: { custom: 'metadata' } + }; + + const result = ResourceLinkSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe('A comprehensive example resource'); + expect(result.data.description).toBe('This resource demonstrates all fields'); + expect(result.data.mimeType).toBe('text/plain'); + expect(result.data._meta).toEqual({ custom: 'metadata' }); + } + }); + + test('should fail validation for invalid type', () => { + const invalidResourceLink = { + type: 'invalid_type', + uri: 'file:///path/to/file.txt', + name: 'file.txt' + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + + test('should fail validation for missing required fields', () => { + const invalidResourceLink = { + type: 'resource_link', + uri: 'file:///path/to/file.txt' + // missing name + }; + + const result = ResourceLinkSchema.safeParse(invalidResourceLink); + expect(result.success).toBe(false); + }); + }); + + describe('ContentBlock', () => { + test('should validate text content', () => { + const mockDate = new Date().toISOString(); + const textContent = { + type: 'text', + text: 'Hello, world!', + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }; + + const result = ContentBlockSchema.safeParse(textContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('text'); + expect(result.data.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + } + }); + + test('should validate image content', () => { + const mockDate = new Date().toISOString(); + const imageContent = { + type: 'image', + data: 'aGVsbG8=', // base64 encoded "hello" + mimeType: 'image/png', + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }; + + const result = ContentBlockSchema.safeParse(imageContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('image'); + expect(result.data.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + } + }); + + test('should validate audio content', () => { + const mockDate = new Date().toISOString(); + const audioContent = { + type: 'audio', + data: 'aGVsbG8=', // base64 encoded "hello" + mimeType: 'audio/mp3', + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }; + + const result = ContentBlockSchema.safeParse(audioContent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('audio'); + expect(result.data.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + } + }); + + test('should validate resource link content', () => { + const mockDate = new Date().toISOString(); + const resourceLink = { + type: 'resource_link', + uri: 'file:///path/to/file.txt', + name: 'file.txt', + mimeType: 'text/plain', + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }; + + const result = ContentBlockSchema.safeParse(resourceLink); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('resource_link'); + expect(result.data.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + } + }); + + test('should validate embedded resource content', () => { + const mockDate = new Date().toISOString(); + const embeddedResource = { + type: 'resource', + resource: { + uri: 'file:///path/to/file.txt', + mimeType: 'text/plain', + text: 'File contents' + }, + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }; + + const result = ContentBlockSchema.safeParse(embeddedResource); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('resource'); + expect(result.data.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + } + }); + }); + + describe('PromptMessage with ContentBlock', () => { + test('should validate prompt message with resource link', () => { + const promptMessage = { + role: 'assistant', + content: { + type: 'resource_link', + uri: 'file:///project/src/main.rs', + name: 'main.rs', + description: 'Primary application entry point', + mimeType: 'text/x-rust' + } + }; + + const result = PromptMessageSchema.safeParse(promptMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content.type).toBe('resource_link'); + } + }); + }); + + describe('CallToolResult with ContentBlock', () => { + test('should validate tool result with resource links', () => { + const toolResult = { + content: [ + { + type: 'text', + text: 'Found the following files:' + }, + { + type: 'resource_link', + uri: 'file:///project/src/main.rs', + name: 'main.rs', + description: 'Primary application entry point', + mimeType: 'text/x-rust' + }, + { + type: 'resource_link', + uri: 'file:///project/src/lib.rs', + name: 'lib.rs', + description: 'Library exports', + mimeType: 'text/x-rust' + } + ] + }; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toHaveLength(3); + expect(result.data.content[0]?.type).toBe('text'); + expect(result.data.content[1]?.type).toBe('resource_link'); + expect(result.data.content[2]?.type).toBe('resource_link'); + } + }); + + test('should validate empty content array with default', () => { + const toolResult = {}; + + const result = CallToolResultSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.content).toEqual([]); + } + }); + }); + + describe('CompleteRequest', () => { + test('should validate a CompleteRequest without resolved field', () => { + const request = { + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: 'A' } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.method).toBe('completion/complete'); + expect(result.data.params.ref.type).toBe('ref/prompt'); + expect(result.data.params.context).toBeUndefined(); + } + }); + + test('should validate a CompleteRequest with resolved field', () => { + const request = { + method: 'completion/complete', + params: { + ref: { type: 'ref/resource', uri: 'github://repos/{owner}/{repo}' }, + argument: { name: 'repo', value: 't' }, + context: { + arguments: { + '{owner}': 'microsoft' + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + '{owner}': 'microsoft' + }); + } + }); + + test('should validate a CompleteRequest with empty resolved field', () => { + const request = { + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'test' }, + argument: { name: 'arg', value: '' }, + context: { + arguments: {} + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({}); + } + }); + + test('should validate a CompleteRequest with multiple resolved variables', () => { + const request = { + method: 'completion/complete', + params: { + ref: { type: 'ref/resource', uri: 'api://v1/{tenant}/{resource}/{id}' }, + argument: { name: 'id', value: '123' }, + context: { + arguments: { + '{tenant}': 'acme-corp', + '{resource}': 'users' + } + } + } + }; + + const result = CompleteRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.context?.arguments).toEqual({ + '{tenant}': 'acme-corp', + '{resource}': 'users' + }); + } + }); + }); + + describe('ToolSchema - JSON Schema 2020-12 support', () => { + test('should accept inputSchema with $schema field', () => { + const tool = { + name: 'test', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with additionalProperties', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with composition keywords', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + allOf: [{ properties: { a: { type: 'string' } } }, { properties: { b: { type: 'number' } } }] + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with $ref and $defs', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { user: { $ref: '#/$defs/User' } }, + $defs: { + User: { type: 'object', properties: { name: { type: 'string' } } } + } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with metadata keywords', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + title: 'User Input', + description: 'Input parameters for user creation', + deprecated: false, + examples: [{ name: 'John' }], + properties: { name: { type: 'string' } } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept outputSchema with full JSON Schema features', () => { + const tool = { + name: 'test', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + tags: { type: 'array' } + }, + required: ['id'], + additionalProperties: false, + minProperties: 1 + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should still require type: object at root for inputSchema', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'string' + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(false); + }); + + test('should still require type: object at root for outputSchema', () => { + const tool = { + name: 'test', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'array' + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(false); + }); + + test('should accept simple minimal schema (backward compatibility)', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + }); + + describe('ToolUseContent', () => { + test('should validate a tool call content', () => { + const toolCall = { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'San Francisco', units: 'celsius' } + }; + + const result = ToolUseContentSchema.safeParse(toolCall); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('tool_use'); + expect(result.data.id).toBe('call_123'); + expect(result.data.name).toBe('get_weather'); + expect(result.data.input).toEqual({ city: 'San Francisco', units: 'celsius' }); + } + }); + + test('should validate tool call with _meta', () => { + const toolCall = { + type: 'tool_use', + id: 'call_456', + name: 'search', + input: { query: 'test' }, + _meta: { custom: 'data' } + }; + + const result = ToolUseContentSchema.safeParse(toolCall); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data._meta).toEqual({ custom: 'data' }); + } + }); + + test('should fail validation for missing required fields', () => { + const invalidToolCall = { + type: 'tool_use', + name: 'test' + // missing id and input + }; + + const result = ToolUseContentSchema.safeParse(invalidToolCall); + expect(result.success).toBe(false); + }); + }); + + describe('ToolResultContent', () => { + test('should validate a tool result content', () => { + const toolResult = { + type: 'tool_result', + toolUseId: 'call_123', + structuredContent: { temperature: 72, condition: 'sunny' } + }; + + const result = ToolResultContentSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('tool_result'); + expect(result.data.toolUseId).toBe('call_123'); + expect(result.data.structuredContent).toEqual({ temperature: 72, condition: 'sunny' }); + } + }); + + test('should validate tool result with error in content', () => { + const toolResult = { + type: 'tool_result', + toolUseId: 'call_456', + structuredContent: { error: 'API_ERROR', message: 'Service unavailable' }, + isError: true + }; + + const result = ToolResultContentSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.structuredContent).toEqual({ error: 'API_ERROR', message: 'Service unavailable' }); + expect(result.data.isError).toBe(true); + } + }); + + test('should fail validation for missing required fields', () => { + const invalidToolResult = { + type: 'tool_result', + content: { data: 'test' } + // missing toolUseId + }; + + const result = ToolResultContentSchema.safeParse(invalidToolResult); + expect(result.success).toBe(false); + }); + }); + + describe('ToolChoice', () => { + test('should validate tool choice with mode auto', () => { + const toolChoice = { + mode: 'auto' + }; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mode).toBe('auto'); + } + }); + + test('should validate tool choice with mode required', () => { + const toolChoice = { + mode: 'required' + }; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mode).toBe('required'); + } + }); + + test('should validate empty tool choice', () => { + const toolChoice = {}; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + }); + + test('should fail validation for invalid mode', () => { + const invalidToolChoice = { + mode: 'invalid' + }; + + const result = ToolChoiceSchema.safeParse(invalidToolChoice); + expect(result.success).toBe(false); + }); + }); + + describe('SamplingMessage content types', () => { + test('should validate user message with text', () => { + const userMessage = { + role: 'user', + content: { type: 'text', text: "What's the weather?" } + }; + + const result = SamplingMessageSchema.safeParse(userMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('user'); + if (!Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('text'); + } + } + }); + + test('should validate user message with tool result', () => { + const userMessage = { + role: 'user', + content: { + type: 'tool_result', + toolUseId: 'call_123', + content: [] + } + }; + + const result = SamplingMessageSchema.safeParse(userMessage); + expect(result.success).toBe(true); + if (result.success && !Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('tool_result'); + } + }); + + test('should validate assistant message with text', () => { + const assistantMessage = { + role: 'assistant', + content: { type: 'text', text: "I'll check the weather for you." } + }; + + const result = SamplingMessageSchema.safeParse(assistantMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('assistant'); + } + }); + + test('should validate assistant message with tool call', () => { + const assistantMessage = { + role: 'assistant', + content: { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + } + }; + + const result = SamplingMessageSchema.safeParse(assistantMessage); + expect(result.success).toBe(true); + if (result.success && !Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('tool_use'); + } + }); + + test('should validate any content type for any role', () => { + // The simplified schema allows any content type for any role + const assistantWithToolResult = { + role: 'assistant', + content: { + type: 'tool_result', + toolUseId: 'call_123', + content: [] + } + }; + + const result1 = SamplingMessageSchema.safeParse(assistantWithToolResult); + expect(result1.success).toBe(true); + + const userWithToolUse = { + role: 'user', + content: { + type: 'tool_use', + id: 'call_123', + name: 'test', + input: {} + } + }; + + const result2 = SamplingMessageSchema.safeParse(userWithToolUse); + expect(result2.success).toBe(true); + }); + }); + + describe('SamplingMessage', () => { + test('should validate user message via discriminated union', () => { + const message = { + role: 'user', + content: { type: 'text', text: 'Hello' } + }; + + const result = SamplingMessageSchema.safeParse(message); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('user'); + } + }); + + test('should validate assistant message via discriminated union', () => { + const message = { + role: 'assistant', + content: { type: 'text', text: 'Hi there!' } + }; + + const result = SamplingMessageSchema.safeParse(message); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('assistant'); + } + }); + }); + + describe('CreateMessageRequest', () => { + test('should validate request without tools', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 1000 + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.tools).toBeUndefined(); + } + }); + + test('should validate request with tools', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: "What's the weather?" } }], + maxTokens: 1000, + tools: [ + { + name: 'get_weather', + description: 'Get weather for a location', + inputSchema: { + type: 'object', + properties: { + location: { type: 'string' } + }, + required: ['location'] + } + } + ], + toolChoice: { + mode: 'auto' + } + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.tools).toHaveLength(1); + expect(result.data.params.toolChoice?.mode).toBe('auto'); + } + }); + + test('should validate request with includeContext (soft-deprecated)', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'Help' } }], + maxTokens: 1000, + includeContext: 'thisServer' + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.includeContext).toBe('thisServer'); + } + }); + }); + + describe('CreateMessageResult', () => { + test('should validate result with text content', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: { type: 'text', text: "Here's the answer." }, + stopReason: 'endTurn' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.role).toBe('assistant'); + expect(parseResult.data.stopReason).toBe('endTurn'); + } + }); + + test('should validate result with tool call (using WithTools schema)', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + }, + stopReason: 'toolUse' + }; + + // Tool call results use CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.stopReason).toBe('toolUse'); + const content = parseResult.data.content; + expect(Array.isArray(content)).toBe(false); + if (!Array.isArray(content)) { + expect(content.type).toBe('tool_use'); + } + } + + // Basic CreateMessageResultSchema should NOT accept tool_use content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); + }); + + test('should validate result with array content (using WithTools schema)', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me check the weather.' }, + { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + } + ], + stopReason: 'toolUse' + }; + + // Array content uses CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.stopReason).toBe('toolUse'); + const content = parseResult.data.content; + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content).toHaveLength(2); + expect(content[0]?.type).toBe('text'); + expect(content[1]?.type).toBe('tool_use'); + } + } + + // Basic CreateMessageResultSchema should NOT accept array content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); + }); + + test('should validate all new stop reasons', () => { + const stopReasons = ['endTurn', 'stopSequence', 'maxTokens', 'toolUse', 'refusal', 'other']; + + for (const stopReason of stopReasons) { + const result = { + model: 'test', + role: 'assistant', + content: { type: 'text', text: 'test' }, + stopReason + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + } + }); + + test('should allow custom stop reason string', () => { + const result = { + model: 'test', + role: 'assistant', + content: { type: 'text', text: 'test' }, + stopReason: 'custom_provider_reason' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + }); + }); + + describe('ClientCapabilities with sampling', () => { + test('should validate capabilities with sampling.tools', () => { + const capabilities = { + sampling: { + tools: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.tools).toBeDefined(); + } + }); + + test('should validate capabilities with sampling.context', () => { + const capabilities = { + sampling: { + context: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.context).toBeDefined(); + } + }); + + test('should validate capabilities with both', () => { + const capabilities = { + sampling: { + context: {}, + tools: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.context).toBeDefined(); + expect(result.data.sampling?.tools).toBeDefined(); + } + }); + }); + + describe('ElicitRequestFormParamsSchema', () => { + test('accepts requestedSchema with extra JSON Schema metadata keys', () => { + // Mirrors what z.toJSONSchema() emits — includes $schema, additionalProperties, etc. + // See https://github.com/modelcontextprotocol/typescript-sdk/issues/1362 + const params = { + message: 'Please provide your name', + requestedSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'], + additionalProperties: false + } + }; + + const result = ElicitRequestFormParamsSchema.safeParse(params); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.requestedSchema.type).toBe('object'); + expect(result.data.requestedSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); + expect(result.data.requestedSchema.additionalProperties).toBe(false); + } + }); + }); +}); diff --git a/packages/core/test/types/guards.test.ts b/packages/core/test/types/guards.test.ts new file mode 100644 index 0000000..117e9ec --- /dev/null +++ b/packages/core/test/types/guards.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { JSONRPC_VERSION } from '../../src/types/constants.js'; +import { isCallToolResult, isJSONRPCErrorResponse, isJSONRPCResponse, isJSONRPCResultResponse } from '../../src/types/guards.js'; + +describe('isJSONRPCResponse', () => { + it('returns true for a valid result response', () => { + expect( + isJSONRPCResponse({ + jsonrpc: JSONRPC_VERSION, + id: 1, + result: {} + }) + ).toBe(true); + }); + + it('returns true for a valid error response', () => { + expect( + isJSONRPCResponse({ + jsonrpc: JSONRPC_VERSION, + id: 1, + error: { code: -32_600, message: 'Invalid Request' } + }) + ).toBe(true); + }); + + it('returns false for a request', () => { + expect( + isJSONRPCResponse({ + jsonrpc: JSONRPC_VERSION, + id: 1, + method: 'test' + }) + ).toBe(false); + }); + + it('returns false for a notification', () => { + expect( + isJSONRPCResponse({ + jsonrpc: JSONRPC_VERSION, + method: 'test' + }) + ).toBe(false); + }); + + it('returns false for arbitrary objects', () => { + expect(isJSONRPCResponse({ foo: 'bar' })).toBe(false); + }); + + it('narrows the type correctly', () => { + const value: unknown = { + jsonrpc: JSONRPC_VERSION, + id: 1, + result: { content: [] } + }; + if (isJSONRPCResponse(value)) { + // Type should be narrowed to JSONRPCResponse + expect(value.jsonrpc).toBe(JSONRPC_VERSION); + expect(value.id).toBe(1); + } + }); + + it('agrees with isJSONRPCResultResponse || isJSONRPCErrorResponse', () => { + const values = [ + { jsonrpc: JSONRPC_VERSION, id: 1, result: {} }, + { jsonrpc: JSONRPC_VERSION, id: 2, error: { code: -1, message: 'err' } }, + { jsonrpc: JSONRPC_VERSION, id: 3, method: 'test' }, + { jsonrpc: JSONRPC_VERSION, method: 'notify' }, + { foo: 'bar' }, + null, + 42 + ]; + for (const v of values) { + expect(isJSONRPCResponse(v)).toBe(isJSONRPCResultResponse(v) || isJSONRPCErrorResponse(v)); + } + }); +}); + +describe('isCallToolResult', () => { + it('returns false for an empty object (content is required)', () => { + expect(isCallToolResult({})).toBe(false); + }); + + it('returns true for a result with content', () => { + expect( + isCallToolResult({ + content: [{ type: 'text', text: 'hello' }] + }) + ).toBe(true); + }); + + it('returns true for a result with isError', () => { + expect( + isCallToolResult({ + content: [{ type: 'text', text: 'fail' }], + isError: true + }) + ).toBe(true); + }); + + it('returns true for a result with structuredContent', () => { + expect( + isCallToolResult({ + content: [], + structuredContent: { key: 'value' } + }) + ).toBe(true); + }); + + it('returns false for non-objects', () => { + expect(isCallToolResult(null)).toBe(false); + expect(isCallToolResult(42)).toBe(false); + expect(isCallToolResult('string')).toBe(false); + }); + + it('returns false for invalid content items', () => { + expect( + isCallToolResult({ + content: [{ type: 'invalid' }] + }) + ).toBe(false); + }); +}); diff --git a/packages/core/test/types/specTypeSchema.test.ts b/packages/core/test/types/specTypeSchema.test.ts new file mode 100644 index 0000000..198e104 --- /dev/null +++ b/packages/core/test/types/specTypeSchema.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; + +import type { OAuthMetadata, OAuthTokens } from '../../src/shared/auth.js'; +import * as schemas from '../../src/types/schemas.js'; +import type { SpecTypeName, SpecTypes } from '../../src/types/specTypeSchema.js'; +import { isSpecType, specTypeSchemas } from '../../src/types/specTypeSchema.js'; +import type { + CallToolResult, + ContentBlock, + Implementation, + JSONObject, + JSONRPCRequest, + JSONValue, + ResourceTemplateType, + Tool +} from '../../src/types/types.js'; + +describe('specTypeSchemas', () => { + it('returns a StandardSchemaV1Sync validator that accepts valid values', () => { + const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x', version: '1.0.0' }); + expect(result.issues).toBeUndefined(); + }); + + it('returns a validator that rejects invalid values with issues', () => { + const result = specTypeSchemas.Implementation['~standard'].validate({ name: 'x' }); + expect(result.issues?.length).toBeGreaterThan(0); + }); + + it('rejects unknown names at compile time and is undefined at runtime', () => { + // @ts-expect-error - 'NotASpecType' is not a SpecTypeName + expect(specTypeSchemas['NotASpecType']).toBeUndefined(); + }); + + it('covers JSON-RPC envelope types', () => { + const ok = specTypeSchemas.JSONRPCRequest['~standard'].validate({ jsonrpc: '2.0', id: 1, method: 'ping' }); + expect(ok.issues).toBeUndefined(); + }); + + it('covers OAuth types from shared/auth.ts', () => { + const ok = specTypeSchemas.OAuthTokens['~standard'].validate({ access_token: 'x', token_type: 'Bearer' }); + expect(ok.issues).toBeUndefined(); + const bad = specTypeSchemas.OAuthTokens['~standard'].validate({ token_type: 'Bearer' }); + expect(bad.issues?.length).toBeGreaterThan(0); + }); +}); + +describe('isSpecType', () => { + it('CallToolResult — accepts valid, rejects invalid/null/primitive', () => { + expect(isSpecType.CallToolResult({ content: [{ type: 'text', text: 'hi' }] })).toBe(true); + expect(isSpecType.CallToolResult({ content: 'not-an-array' })).toBe(false); + expect(isSpecType.CallToolResult(null)).toBe(false); + expect(isSpecType.CallToolResult('string')).toBe(false); + }); + + it('ContentBlock — accepts text block, rejects wrong shape', () => { + expect(isSpecType.ContentBlock({ type: 'text', text: 'hi' })).toBe(true); + expect(isSpecType.ContentBlock({ type: 'text' })).toBe(false); + expect(isSpecType.ContentBlock({})).toBe(false); + }); + + it('Tool — accepts valid, rejects missing inputSchema', () => { + expect(isSpecType.Tool({ name: 'echo', inputSchema: { type: 'object' } })).toBe(true); + expect(isSpecType.Tool({ name: 'echo' })).toBe(false); + }); + + it('ResourceTemplate — accepts valid, rejects missing uriTemplate', () => { + expect(isSpecType.ResourceTemplate({ name: 'r', uriTemplate: 'file:///{path}' })).toBe(true); + expect(isSpecType.ResourceTemplate({ name: 'r' })).toBe(false); + }); + + it('rejects unknown names at compile time and is undefined at runtime', () => { + // @ts-expect-error - 'NotASpecType' is not a SpecTypeName + expect(isSpecType['NotASpecType']).toBeUndefined(); + }); + + it('excludes internal helper schemas (no matching public type)', () => { + // @ts-expect-error - ListChangedOptionsBase is internal-only + expect(isSpecType['ListChangedOptionsBase']).toBeUndefined(); + // @ts-expect-error - BaseRequestParams is internal-only + expect(specTypeSchemas['BaseRequestParams']).toBeUndefined(); + // @ts-expect-error - NotificationsParams is internal-only + expect(isSpecType['NotificationsParams']).toBeUndefined(); + }); + + it('narrows the value type to the schema input type', () => { + const v: unknown = { name: 'x', version: '1.0.0' }; + if (isSpecType.Implementation(v)) { + // ImplementationSchema has no defaults/transforms, so its input type equals Implementation. + expectTypeOf(v).toEqualTypeOf(); + } + }); + + it('narrows to the input type, not the output type, for schemas with defaults', () => { + const v: unknown = {}; + expect(isSpecType.CallToolResult(v)).toBe(true); + if (isSpecType.CallToolResult(v)) { + // CallToolResultSchema has `content: z.array(...).default([])`, so the input type + // permits `content` to be absent. The guard narrows to that input shape. + expectTypeOf(v.content).toEqualTypeOf(); + expectTypeOf(v).not.toEqualTypeOf(); + } + }); + + it('JSONValue / JSONObject — narrows to the JSON type, not unknown', () => { + // These schemas use an explicit z.ZodType annotation for recursion; without the + // second param Zod's Input defaults to `unknown` and the predicate would not narrow. + const v: unknown = { a: 1 }; + if (isSpecType.JSONValue(v)) { + expectTypeOf(v).toEqualTypeOf(); + } + if (isSpecType.JSONObject(v)) { + expectTypeOf(v).toEqualTypeOf(); + } + }); + + it('guards work as filter callbacks and narrow the element type', () => { + const mixed: unknown[] = [{ type: 'text', text: 'hi' }, 42, { type: 'text' }]; + const blocks = mixed.filter(isSpecType.ContentBlock); + expect(blocks).toHaveLength(1); + expectTypeOf(blocks).toEqualTypeOf(); + }); +}); + +describe('SpecTypeName / SpecTypes (type-level)', () => { + it('SpecTypeName includes representative names', () => { + expectTypeOf<'CallToolResult'>().toMatchTypeOf(); + expectTypeOf<'ContentBlock'>().toMatchTypeOf(); + expectTypeOf<'Tool'>().toMatchTypeOf(); + expectTypeOf<'Implementation'>().toMatchTypeOf(); + expectTypeOf<'JSONRPCRequest'>().toMatchTypeOf(); + expectTypeOf<'OAuthTokens'>().toMatchTypeOf(); + expectTypeOf<'OAuthMetadata'>().toMatchTypeOf(); + expectTypeOf<'ResourceTemplate'>().toMatchTypeOf(); + }); + + it('SpecTypes[K] matches the named export type', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + // The public type is exported as ResourceTemplateType (the bare name collides with the + // server package's ResourceTemplate class), so this is the one entry where the key and + // the public type name differ. + expectTypeOf().toEqualTypeOf(); + }); +}); + +describe('SPEC_SCHEMA_KEYS allowlist', () => { + // Mirrors the exclusion comment in specTypeSchema.ts. If this list grows, confirm the new + // entry has no public type in types.ts before adding it here; otherwise add it to the allowlist. + const INTERNAL_HELPER_SCHEMAS: readonly string[] = [ + 'ListChangedOptionsBaseSchema', + 'BaseRequestParamsSchema', + 'NotificationsParamsSchema', + 'ClientTasksCapabilitySchema', + 'ServerTasksCapabilitySchema' + ]; + + it('covers every public protocol schema in schemas.ts (drift guard)', () => { + // PascalCase filters out helper functions like getRequestSchema/getResultSchema. + const allProtocolSchemas = Object.keys(schemas).filter(k => k.endsWith('Schema') && /^[A-Z]/.test(k)); + const expected = allProtocolSchemas + .filter(k => !INTERNAL_HELPER_SCHEMAS.includes(k)) + .map(k => k.slice(0, -'Schema'.length)) + .sort(); + // Auth schemas are sourced from shared/auth.ts, not schemas.ts, so filter them out of the + // observed side before comparing. + const actual = Object.keys(isSpecType) + .filter(k => !k.startsWith('OAuth') && !k.startsWith('OpenId')) + .sort(); + expect(actual).toEqual(expected); + }); +}); diff --git a/packages/core/test/util/standardSchema.test.ts b/packages/core/test/util/standardSchema.test.ts new file mode 100644 index 0000000..6c3de99 --- /dev/null +++ b/packages/core/test/util/standardSchema.test.ts @@ -0,0 +1,42 @@ +import * as z from 'zod/v4'; + +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; + +describe('standardSchemaToJsonSchema', () => { + test('emits type:object for plain z.object schemas', () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(result.type).toBe('object'); + expect(result.properties).toBeDefined(); + }); + + test('emits type:object for discriminated unions', () => { + const schema = z.discriminatedUnion('action', [ + z.object({ action: z.literal('create'), name: z.string() }), + z.object({ action: z.literal('delete'), id: z.string() }) + ]); + const result = standardSchemaToJsonSchema(schema, 'input'); + + expect(result.type).toBe('object'); + // Zod emits oneOf for discriminated unions; the catchall on Tool.inputSchema + // accepts it, but the top-level type must be present per MCP spec. + expect(result.oneOf ?? result.anyOf).toBeDefined(); + }); + + test('throws for schemas with explicit non-object type', () => { + expect(() => standardSchemaToJsonSchema(z.string(), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.array(z.string()), 'input')).toThrow(/must describe objects/); + expect(() => standardSchemaToJsonSchema(z.number(), 'input')).toThrow(/must describe objects/); + }); + + test('preserves existing type:object without modification', () => { + const schema = z.object({ x: z.string() }); + const result = standardSchemaToJsonSchema(schema, 'input'); + + // Spread order means zod's own type:"object" wins; verify no double-wrap. + const keys = Object.keys(result); + expect(keys.filter(k => k === 'type')).toHaveLength(1); + expect(result.type).toBe('object'); + }); +}); diff --git a/packages/core/test/util/standardSchema.zodFallback.test.ts b/packages/core/test/util/standardSchema.zodFallback.test.ts new file mode 100644 index 0000000..d825a32 --- /dev/null +++ b/packages/core/test/util/standardSchema.zodFallback.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; + +type SchemaArg = Parameters[0]; + +describe('standardSchemaToJsonSchema — zod fallback paths', () => { + it('falls back to z.toJSONSchema for zod 4.0–4.1 (vendor=zod, no ~standard.jsonSchema, has _zod)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const real = z.object({ a: z.string() }); + // Simulate zod 4.0–4.1: shadow `~standard` on the real instance with `jsonSchema` removed. + // Keeps the rest of the zod 4 object (including `_zod`) intact so z.toJSONSchema can introspect it. + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const result = standardSchemaToJsonSchema(real as unknown as SchemaArg); + expect(result.type).toBe('object'); + expect((result.properties as unknown as Record)?.a).toBeDefined(); + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]?.[0]).toContain('zod 4.2.0'); + warn.mockRestore(); + }); + + it('throws a clear error for zod 3 (vendor=zod, no ~standard.jsonSchema, no _zod)', () => { + // zod 3.24+ reports `~standard.vendor === 'zod'` but has no `_zod` internal marker. + const zod3ish = { _def: {}, '~standard': { version: 1, vendor: 'zod', validate: () => ({ value: {} }) } }; + expect(() => standardSchemaToJsonSchema(zod3ish as unknown as SchemaArg)).toThrow(/zod 3/); + expect(() => standardSchemaToJsonSchema(zod3ish as unknown as SchemaArg)).toThrow(/4\.2\.0/); + }); + + it('throws a clear error for non-zod libraries without ~standard.jsonSchema', () => { + const fake = { '~standard': { version: 1, vendor: 'mylib', validate: () => ({ value: {} }) } }; + expect(() => standardSchemaToJsonSchema(fake as unknown as SchemaArg)).toThrow(/mylib/); + expect(() => standardSchemaToJsonSchema(fake as unknown as SchemaArg)).toThrow(/fromJsonSchema/); + }); +}); diff --git a/packages/core/test/util/zodCompat.test.ts b/packages/core/test/util/zodCompat.test.ts new file mode 100644 index 0000000..cf48be3 --- /dev/null +++ b/packages/core/test/util/zodCompat.test.ts @@ -0,0 +1,89 @@ +import { vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { standardSchemaToJsonSchema } from '../../src/util/standardSchema.js'; +import { isZodRawShape, normalizeRawShapeSchema } from '../../src/util/zodCompat.js'; + +describe('isZodRawShape', () => { + test('treats empty object as a raw shape (matches v1)', () => { + expect(isZodRawShape({})).toBe(true); + }); + test('detects raw shape with zod fields', () => { + expect(isZodRawShape({ a: z.string() })).toBe(true); + }); + test('rejects a Standard Schema instance', () => { + expect(isZodRawShape(z.object({ a: z.string() }))).toBe(false); + }); + test('rejects a shape with non-Zod Standard Schema fields', () => { + const nonZod = { '~standard': { version: 1, vendor: 'arktype', validate: () => ({ value: 'x' }) } }; + expect(isZodRawShape({ a: nonZod })).toBe(false); + }); + test('rejects a shape with Zod v3 fields (only v4 is wrappable)', () => { + expect(isZodRawShape({ a: mockZodV3String() })).toBe(false); + }); + test('rejects non-plain objects with no own-enumerable properties', () => { + expect(isZodRawShape([])).toBe(false); + expect(isZodRawShape([z.string()])).toBe(false); + expect(isZodRawShape(new Date())).toBe(false); + expect(isZodRawShape(new Map())).toBe(false); + expect(isZodRawShape(/regex/)).toBe(false); + }); + test('accepts a null-prototype plain object', () => { + const o = Object.create(null); + o.a = z.string(); + expect(isZodRawShape(o)).toBe(true); + }); +}); + +// Minimal structural mock of a Zod v3 schema: has `_def.typeName` and +// `~standard.vendor === 'zod'` (zod >=3.24), but no `_zod`. +function mockZodV3String(): unknown { + return { + _def: { typeName: 'ZodString', checks: [], coerce: false }, + '~standard': { version: 1, vendor: 'zod', validate: (v: unknown) => ({ value: v }) }, + parse: (v: unknown) => v + }; +} + +describe('normalizeRawShapeSchema', () => { + test('wraps empty raw shape into z.object({})', () => { + const wrapped = normalizeRawShapeSchema({}); + expect(wrapped).toBeDefined(); + expect(standardSchemaToJsonSchema(wrapped!, 'input').type).toBe('object'); + }); + test('passes through an already-wrapped Standard Schema unchanged', () => { + const schema = z.object({ a: z.string() }); + expect(normalizeRawShapeSchema(schema)).toBe(schema); + }); + test('returns undefined for undefined input', () => { + expect(normalizeRawShapeSchema(undefined)).toBeUndefined(); + }); + test('throws TypeError for an invalid object that is neither raw shape nor Standard Schema', () => { + expect(() => normalizeRawShapeSchema({ a: 'not a zod schema' } as never)).toThrow(TypeError); + }); + test('passes through a Standard Schema without `~standard.jsonSchema` (per-vendor handling deferred to standardSchemaToJsonSchema)', () => { + const noJson = { '~standard': { version: 1, vendor: 'x', validate: () => ({ value: {} }) } }; + expect(normalizeRawShapeSchema(noJson as never)).toBe(noJson); + }); + test('passes through a zod 4.0-4.1 schema so standardSchemaToJsonSchema can apply its z.toJSONSchema fallback', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const real = z.object({ a: z.string() }); + // Simulate zod 4.0-4.1: shadow `~standard` with `jsonSchema` removed, keep `_zod` intact. + const { jsonSchema: _drop, ...stdNoJson } = real['~standard'] as unknown as Record; + void _drop; + Object.defineProperty(real, '~standard', { value: { ...stdNoJson, vendor: 'zod' }, configurable: true }); + + const normalized = normalizeRawShapeSchema(real); + expect(normalized).toBe(real); + const json = standardSchemaToJsonSchema(normalized!, 'input'); + expect(json.type).toBe('object'); + expect((json.properties as Record)?.a).toBeDefined(); + warn.mockRestore(); + }); + test('throws actionable TypeError for a raw shape with Zod v3 fields', () => { + expect(() => normalizeRawShapeSchema({ a: mockZodV3String() } as never)).toThrow(/Zod v4 schemas.*Got a Zod v3 field schema/); + }); + test('throws the intended TypeError (not Object.values crash) for null input', () => { + expect(() => normalizeRawShapeSchema(null as never)).toThrow(/must be a Standard Schema/); + }); +}); diff --git a/packages/core/test/validators/validators.test.ts b/packages/core/test/validators/validators.test.ts new file mode 100644 index 0000000..6c543cb --- /dev/null +++ b/packages/core/test/validators/validators.test.ts @@ -0,0 +1,625 @@ +/** + * Tests all validator providers with various JSON Schema 2020-12 features + * Based on MCP specification for elicitation schemas: + * https://modelcontextprotocol.io/specification/draft/client/elicitation.md + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +import { vi } from 'vitest'; + +import { AjvJsonSchemaValidator } from '../../src/validators/ajvProvider.js'; +import { CfWorkerJsonSchemaValidator } from '../../src/validators/cfWorkerProvider.js'; +import type { JsonSchemaType } from '../../src/validators/types.js'; + +// Test with both AJV and CfWorker validators +// AJV validator will use default configuration with format validation enabled +const validators = [ + { name: 'AJV', provider: new AjvJsonSchemaValidator() }, + { name: 'CfWorker', provider: new CfWorkerJsonSchemaValidator() } +]; + +describe('JSON Schema Validators', () => { + describe.each(validators)('$name Validator', ({ provider }) => { + describe('String schemas', () => { + it('validates basic string', () => { + const schema: JsonSchemaType = { + type: 'string' + }; + const validator = provider.getValidator(schema); + + const validResult = validator('hello'); + expect(validResult.valid).toBe(true); + expect(validResult.data).toBe('hello'); + + const invalidResult = validator(123); + expect(invalidResult.valid).toBe(false); + expect(invalidResult.errorMessage).toBeDefined(); + }); + + it('validates string with title and description', () => { + const schema: JsonSchemaType = { + type: 'string', + title: 'Name', + description: "User's full name" + }; + const validator = provider.getValidator(schema); + + const result = validator('John Doe'); + expect(result.valid).toBe(true); + expect(result.data).toBe('John Doe'); + }); + + it('validates string with length constraints', () => { + const schema: JsonSchemaType = { + type: 'string', + minLength: 3, + maxLength: 10 + }; + const validator = provider.getValidator(schema); + + expect(validator('abc').valid).toBe(true); + expect(validator('abcdefghij').valid).toBe(true); + expect(validator('ab').valid).toBe(false); + expect(validator('abcdefghijk').valid).toBe(false); + }); + + it('validates email format', () => { + const schema: JsonSchemaType = { + type: 'string', + format: 'email' + }; + const validator = provider.getValidator(schema); + + expect(validator('user@example.com').valid).toBe(true); + expect(validator('invalid-email').valid).toBe(false); + }); + + it('validates URI format', () => { + const schema: JsonSchemaType = { + type: 'string', + format: 'uri' + }; + const validator = provider.getValidator(schema); + + expect(validator('https://example.com').valid).toBe(true); + expect(validator('not-a-uri').valid).toBe(false); + }); + + it('validates date-time format', () => { + const schema: JsonSchemaType = { + type: 'string', + format: 'date-time' + }; + const validator = provider.getValidator(schema); + + expect(validator('2025-10-17T12:00:00Z').valid).toBe(true); + expect(validator('not-a-date').valid).toBe(false); + }); + + it('validates string pattern', () => { + const schema: JsonSchemaType = { + type: 'string', + pattern: '^[A-Z]{3}$' + }; + const validator = provider.getValidator(schema); + + expect(validator('ABC').valid).toBe(true); + expect(validator('abc').valid).toBe(false); + expect(validator('ABCD').valid).toBe(false); + }); + }); + + describe('Number schemas', () => { + it('validates number type', () => { + const schema: JsonSchemaType = { + type: 'number' + }; + const validator = provider.getValidator(schema); + + expect(validator(42).valid).toBe(true); + expect(validator(3.14).valid).toBe(true); + expect(validator('42').valid).toBe(false); + }); + + it('validates integer type', () => { + const schema: JsonSchemaType = { + type: 'integer' + }; + const validator = provider.getValidator(schema); + + expect(validator(42).valid).toBe(true); + expect(validator(3.14).valid).toBe(false); + }); + + it('validates number range', () => { + const schema: JsonSchemaType = { + type: 'number', + minimum: 0, + maximum: 100 + }; + const validator = provider.getValidator(schema); + + expect(validator(0).valid).toBe(true); + expect(validator(50).valid).toBe(true); + expect(validator(100).valid).toBe(true); + expect(validator(-1).valid).toBe(false); + expect(validator(101).valid).toBe(false); + }); + }); + + describe('Boolean schemas', () => { + it('validates boolean type', () => { + const schema: JsonSchemaType = { + type: 'boolean' + }; + const validator = provider.getValidator(schema); + + expect(validator(true).valid).toBe(true); + expect(validator(false).valid).toBe(true); + expect(validator('true').valid).toBe(false); + expect(validator(1).valid).toBe(false); + }); + + it('validates boolean with default', () => { + const schema: JsonSchemaType = { + type: 'boolean', + default: false + }; + const validator = provider.getValidator(schema); + + expect(validator(true).valid).toBe(true); + expect(validator(false).valid).toBe(true); + }); + }); + + describe('Enum schemas', () => { + it('validates enum values', () => { + const schema: JsonSchemaType = { + enum: ['red', 'green', 'blue'] + }; + const validator = provider.getValidator(schema); + + expect(validator('red').valid).toBe(true); + expect(validator('green').valid).toBe(true); + expect(validator('blue').valid).toBe(true); + expect(validator('yellow').valid).toBe(false); + }); + + it('validates enum with mixed types', () => { + const schema: JsonSchemaType = { + enum: ['option1', 42, true, null] + }; + const validator = provider.getValidator(schema); + + expect(validator('option1').valid).toBe(true); + expect(validator(42).valid).toBe(true); + expect(validator(true).valid).toBe(true); + expect(validator(null).valid).toBe(true); + expect(validator('other').valid).toBe(false); + }); + }); + + describe('Object schemas', () => { + it('validates simple object', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name'] + }; + const validator = provider.getValidator(schema); + + expect(validator({ name: 'John', age: 30 }).valid).toBe(true); + expect(validator({ name: 'John' }).valid).toBe(true); + expect(validator({ age: 30 }).valid).toBe(false); + expect(validator({}).valid).toBe(false); + }); + + it('validates nested objects', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string', format: 'email' } + }, + required: ['name'] + } + }, + required: ['user'] + }; + const validator = provider.getValidator(schema); + + expect( + validator({ + user: { name: 'John', email: 'john@example.com' } + }).valid + ).toBe(true); + + expect( + validator({ + user: { name: 'John' } + }).valid + ).toBe(true); + + expect( + validator({ + user: { email: 'john@example.com' } + }).valid + ).toBe(false); + }); + + it('validates object with additionalProperties: false', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' } + }, + additionalProperties: false + }; + const validator = provider.getValidator(schema); + + expect(validator({ name: 'John' }).valid).toBe(true); + expect(validator({ name: 'John', extra: 'field' }).valid).toBe(false); + }); + }); + + describe('Array schemas', () => { + it('validates array of strings', () => { + const schema: JsonSchemaType = { + type: 'array', + items: { type: 'string' } + }; + const validator = provider.getValidator(schema); + + expect(validator(['a', 'b', 'c']).valid).toBe(true); + expect(validator([]).valid).toBe(true); + expect(validator(['a', 1, 'c']).valid).toBe(false); + }); + + it('validates array length constraints', () => { + const schema: JsonSchemaType = { + type: 'array', + items: { type: 'number' }, + minItems: 1, + maxItems: 3 + }; + const validator = provider.getValidator(schema); + + expect(validator([1]).valid).toBe(true); + expect(validator([1, 2, 3]).valid).toBe(true); + expect(validator([]).valid).toBe(false); + expect(validator([1, 2, 3, 4]).valid).toBe(false); + }); + + it('validates array with unique items', () => { + const schema: JsonSchemaType = { + type: 'array', + items: { type: 'number' }, + uniqueItems: true + }; + const validator = provider.getValidator(schema); + + expect(validator([1, 2, 3]).valid).toBe(true); + expect(validator([1, 2, 2, 3]).valid).toBe(false); + }); + }); + + describe('JSON Schema 2020-12 features', () => { + it('validates schema with $schema field', () => { + const schema: JsonSchemaType = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'string' + }; + const validator = provider.getValidator(schema); + + expect(validator('test').valid).toBe(true); + }); + + it('validates schema with $id field', () => { + const schema: JsonSchemaType = { + $id: 'https://example.com/schemas/test', + type: 'number' + }; + const validator = provider.getValidator(schema); + + expect(validator(42).valid).toBe(true); + }); + + it('validates with allOf', () => { + const schema: JsonSchemaType = { + allOf: [ + { type: 'object', properties: { name: { type: 'string' } } }, + { type: 'object', properties: { age: { type: 'number' } } } + ] + }; + const validator = provider.getValidator(schema); + + expect(validator({ name: 'John', age: 30 }).valid).toBe(true); + expect(validator({ name: 'John' }).valid).toBe(true); + expect(validator({ name: 123 }).valid).toBe(false); + }); + + it('validates with anyOf', () => { + const schema: JsonSchemaType = { + anyOf: [{ type: 'string' }, { type: 'number' }] + }; + const validator = provider.getValidator(schema); + + expect(validator('test').valid).toBe(true); + expect(validator(42).valid).toBe(true); + expect(validator(true).valid).toBe(false); + }); + + it('validates with oneOf', () => { + const schema: JsonSchemaType = { + oneOf: [ + { type: 'string', minLength: 5 }, + { type: 'string', maxLength: 3 } + ] + }; + const validator = provider.getValidator(schema); + + expect(validator('ab').valid).toBe(true); // Matches second only + expect(validator('hello').valid).toBe(true); // Matches first only + expect(validator('abcd').valid).toBe(false); // Matches neither + }); + + it('validates with not', () => { + const schema: JsonSchemaType = { + not: { type: 'null' } + }; + const validator = provider.getValidator(schema); + + expect(validator('test').valid).toBe(true); + expect(validator(42).valid).toBe(true); + expect(validator(null).valid).toBe(false); + }); + + it('validates with const', () => { + const schema: JsonSchemaType = { + const: 'specific-value' + }; + const validator = provider.getValidator(schema); + + expect(validator('specific-value').valid).toBe(true); + expect(validator('other-value').valid).toBe(false); + }); + }); + + describe('Complex real-world schemas', () => { + it('validates user registration form', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + username: { + type: 'string', + minLength: 3, + maxLength: 20, + pattern: '^[a-zA-Z0-9_]+$' + }, + email: { + type: 'string', + format: 'email' + }, + age: { + type: 'integer', + minimum: 18, + maximum: 120 + }, + newsletter: { + type: 'boolean', + default: false + } + }, + required: ['username', 'email'] + }; + const validator = provider.getValidator(schema); + + expect( + validator({ + username: 'john_doe', + email: 'john@example.com', + age: 25, + newsletter: true + }).valid + ).toBe(true); + + expect( + validator({ + username: 'john_doe', + email: 'john@example.com' + }).valid + ).toBe(true); + + expect( + validator({ + username: 'ab', // Too short + email: 'john@example.com' + }).valid + ).toBe(false); + + expect( + validator({ + username: 'john_doe', + email: 'invalid-email' + }).valid + ).toBe(false); + }); + + it('validates API response with nested structure', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['success', 'error', 'pending'] + }, + data: { + type: 'object', + properties: { + id: { type: 'string' }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + quantity: { type: 'integer', minimum: 1 } + }, + required: ['name', 'quantity'] + } + } + }, + required: ['id', 'items'] + }, + timestamp: { + type: 'string', + format: 'date-time' + } + }, + required: ['status', 'data'] + }; + const validator = provider.getValidator(schema); + + expect( + validator({ + status: 'success', + data: { + id: '123', + items: [ + { name: 'Item 1', quantity: 5 }, + { name: 'Item 2', quantity: 3 } + ] + }, + timestamp: '2025-10-17T12:00:00Z' + }).valid + ).toBe(true); + + expect( + validator({ + status: 'invalid-status', + data: { id: '123', items: [] } + }).valid + ).toBe(false); + }); + }); + + describe('Error messages', () => { + it('provides helpful error message on validation failure', () => { + const schema: JsonSchemaType = { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + }; + const validator = provider.getValidator(schema); + + const result = validator({}); + expect(result.valid).toBe(false); + expect(result.errorMessage).toBeDefined(); + expect(result.errorMessage).toBeTruthy(); + expect(typeof result.errorMessage).toBe('string'); + }); + }); + }); +}); + +describe('Missing dependencies', () => { + describe('AJV not installed but CfWorker is', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock('ajv'); + vi.doUnmock('ajv-formats'); + }); + + it('should throw error when trying to import ajv-provider without ajv', async () => { + // Mock ajv as not installed + vi.doMock('ajv', () => { + throw new Error("Cannot find module 'ajv'"); + }); + + vi.doMock('ajv-formats', () => { + throw new Error("Cannot find module 'ajv-formats'"); + }); + + // Attempting to import ajv-provider should fail + await expect(import('../../src/validators/ajvProvider.js')).rejects.toThrow(); + }); + + it('should be able to import cfWorkerProvider when ajv is missing', async () => { + // Mock ajv as not installed + vi.doMock('ajv', () => { + throw new Error("Cannot find module 'ajv'"); + }); + + vi.doMock('ajv-formats', () => { + throw new Error("Cannot find module 'ajv-formats'"); + }); + + // But cfWorkerProvider should import successfully + const cfworkerModule = await import('../../src/validators/cfWorkerProvider.js'); + expect(cfworkerModule.CfWorkerJsonSchemaValidator).toBeDefined(); + + // And should work correctly + const validator = new cfworkerModule.CfWorkerJsonSchemaValidator(); + const schema: JsonSchemaType = { type: 'string' }; + const validatorFn = validator.getValidator(schema); + expect(validatorFn('test').valid).toBe(true); + }); + }); + + describe('CfWorker not installed but AJV is', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock('@cfworker/json-schema'); + }); + + it('should throw error when trying to import cfWorkerProvider without @cfworker/json-schema', async () => { + // Mock @cfworker/json-schema as not installed + vi.doMock('@cfworker/json-schema', () => { + throw new Error("Cannot find module '@cfworker/json-schema'"); + }); + + // Attempting to import cfWorkerProvider should fail + await expect(import('../../src/validators/cfWorkerProvider.js')).rejects.toThrow(); + }); + + it('should be able to import ajv-provider when @cfworker/json-schema is missing', async () => { + // Mock @cfworker/json-schema as not installed + vi.doMock('@cfworker/json-schema', () => { + throw new Error("Cannot find module '@cfworker/json-schema'"); + }); + + // But ajv-provider should import successfully + const ajvModule = await import('../../src/validators/ajvProvider.js'); + expect(ajvModule.AjvJsonSchemaValidator).toBeDefined(); + + // And should work correctly + const validator = new ajvModule.AjvJsonSchemaValidator(); + const schema: JsonSchemaType = { type: 'string' }; + const validatorFn = validator.getValidator(schema); + expect(validatorFn('test').valid).toBe(true); + }); + + it('should document that @cfworker/json-schema is required', () => { + const cfworkerProviderPath = path.join(__dirname, '../../src/validators/cfWorkerProvider.ts'); + const content = readFileSync(cfworkerProviderPath, 'utf8'); + + expect(content).toContain('@cfworker/json-schema'); + }); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..a683830 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/packages/core/vitest.config.js b/packages/core/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/core/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/middleware/README.md b/packages/middleware/README.md new file mode 100644 index 0000000..2dd2335 --- /dev/null +++ b/packages/middleware/README.md @@ -0,0 +1,23 @@ +# Middleware packages + +The packages in `packages/middleware/*` are **thin integration layers** that help you expose an MCP server in a specific runtime, platform, or web framework. + +They intentionally **do not** add new MCP features or “business logic”. MCP functionality (tools, resources, prompts, transports, auth primitives, etc.) lives in `@modelcontextprotocol/server` (and other core packages). Middleware packages should primarily: + +- adapt request/response types to the SDK (e.g. Node.js `IncomingMessage`/`ServerResponse`) +- provide small framework helpers (e.g. wiring, body parsing hooks) +- supply safe defaults for common deployment pitfalls (e.g. localhost DNS rebinding protection) + +## Packages + +- `@modelcontextprotocol/express` — Express helpers (app defaults + Host header validation for DNS rebinding protection). +- `@modelcontextprotocol/hono` — Hono helpers (app defaults + JSON body parsing hook + Host header validation). +- `@modelcontextprotocol/node` — Node.js Streamable HTTP transport wrapper for `IncomingMessage`/`ServerResponse`. + +## Typical usage + +Most servers use: + +- `@modelcontextprotocol/server` for the MCP server implementation +- one middleware package for framework/runtime integration (this folder) +- (optionally) additional platform/framework dependencies (Express, Hono, etc.) diff --git a/packages/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md new file mode 100644 index 0000000..fb0fe43 --- /dev/null +++ b/packages/middleware/express/CHANGELOG.md @@ -0,0 +1,34 @@ +# @modelcontextprotocol/express + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +- Updated dependencies [[`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb)]: + - @modelcontextprotocol/server@2.0.0-alpha.2 + +## 2.0.0-alpha.1 + +### Patch Changes + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- [#1625](https://github.com/modelcontextprotocol/typescript-sdk/pull/1625) [`1fe9eda`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1fe9eda4a712a5f3a3ba11561e723ec7e6cf5a5b) Thanks [@rameshreddy-adutla](https://github.com/rameshreddy-adutla)! - Add jsonLimit + option to createMcpExpressApp + +- Updated dependencies [[`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333), [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4), + [`3466a9e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3466a9e0e5d392824156d9b290863ae08192d87e), [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47), + [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e), [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2), + [`78bae74`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78bae7426d4ca38216c0571b5aa7806f58ab81e4), [`689148d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/689148dc7235f53244869ed64b2ecc9ec4ef70f1), + [`f1ade75`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f1ade75b67a2b46b06316fa4e5caa1d277537cc7), [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`f66a55b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f66a55b5f4eb7ce0f8b3885633bf9a7b1080e0b5), + [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8), + [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac)]: + - @modelcontextprotocol/server@2.0.0-alpha.1 diff --git a/packages/middleware/express/README.md b/packages/middleware/express/README.md new file mode 100644 index 0000000..b837f5b --- /dev/null +++ b/packages/middleware/express/README.md @@ -0,0 +1,68 @@ +# `@modelcontextprotocol/express` + +Express adapters for the MCP TypeScript server SDK. + +This package is a thin Express integration layer for [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/server). + +It does **not** implement MCP itself. Instead, it helps you: + +- create an Express app with sensible defaults for MCP servers +- add DNS rebinding protection via Host header validation (recommended for localhost servers) +- protect routes with `requireBearerAuth` (validates `Authorization: Bearer …` via your `OAuthTokenVerifier`) +- serve OAuth Protected Resource Metadata (RFC 9728) via `mcpAuthMetadataRouter` + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/express express + +# For MCP Streamable HTTP over Node.js (IncomingMessage/ServerResponse): +npm install @modelcontextprotocol/node +``` + +## Exports + +- `createMcpExpressApp(options?)` +- `hostHeaderValidation(allowedHostnames)` +- `localhostHostValidation()` +- `requireBearerAuth(options)` +- `mcpAuthMetadataRouter(options)` +- `getOAuthProtectedResourceMetadataUrl(serverUrl)` +- `OAuthTokenVerifier` (interface) + +## Usage + +### Create an Express app (localhost DNS rebinding protection by default) + +```ts +import { createMcpExpressApp } from '@modelcontextprotocol/express'; + +const app = createMcpExpressApp(); // default host is 127.0.0.1; protection enabled +``` + +### Streamable HTTP endpoint (Express) + +```ts +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; + +const app = createMcpExpressApp(); +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + +app.post('/mcp', async (req, res) => { + // Stateless example: create a transport per request. + // For stateful mode (sessions), keep a transport instance around and reuse it. + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); +``` + +### Host header validation (DNS rebinding protection) + +```ts +import { hostHeaderValidation } from '@modelcontextprotocol/express'; + +app.use(hostHeaderValidation(['localhost', '127.0.0.1', '[::1]'])); +``` diff --git a/packages/middleware/express/eslint.config.mjs b/packages/middleware/express/eslint.config.mjs new file mode 100644 index 0000000..03d5331 --- /dev/null +++ b/packages/middleware/express/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/(server|core)' + } + } +]; diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json new file mode 100644 index 0000000..b0b6953 --- /dev/null +++ b/packages/middleware/express/package.json @@ -0,0 +1,73 @@ +{ + "name": "@modelcontextprotocol/express", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "express", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "cors": "catalog:runtimeServerOnly" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "express": "^4.18.0 || ^5.0.0" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@types/cors": "catalog:devTools", + "@types/express": "catalog:devTools", + "@types/express-serve-static-core": "catalog:devTools", + "@types/supertest": "catalog:devTools", + "supertest": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/middleware/express/src/auth/bearerAuth.ts b/packages/middleware/express/src/auth/bearerAuth.ts new file mode 100644 index 0000000..5f46be7 --- /dev/null +++ b/packages/middleware/express/src/auth/bearerAuth.ts @@ -0,0 +1,120 @@ +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import type { RequestHandler } from 'express'; + +import type { OAuthTokenVerifier } from './types.js'; + +/** + * Options for {@link requireBearerAuth}. + */ +export interface BearerAuthMiddlewareOptions { + /** + * A verifier used to validate access tokens. + */ + verifier: OAuthTokenVerifier; + + /** + * Optional scopes that the token must have. When any are missing the + * middleware responds with `403 insufficient_scope`. + */ + requiredScopes?: string[]; + + /** + * Optional Protected Resource Metadata URL to advertise in the + * `WWW-Authenticate` header on 401/403 responses, per + * {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}. + * + * Typically built with `getOAuthProtectedResourceMetadataUrl`. + */ + resourceMetadataUrl?: string; +} + +function buildWwwAuthenticateHeader( + errorCode: string, + description: string, + requiredScopes: string[], + resourceMetadataUrl: string | undefined +): string { + let header = `Bearer error="${errorCode}", error_description="${description}"`; + if (requiredScopes.length > 0) { + header += `, scope="${requiredScopes.join(' ')}"`; + } + if (resourceMetadataUrl) { + header += `, resource_metadata="${resourceMetadataUrl}"`; + } + return header; +} + +/** + * Express middleware that requires a valid Bearer token in the `Authorization` + * header. + * + * The token is validated via the supplied {@link OAuthTokenVerifier} and the + * resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached + * to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and + * surfaces it to handlers as `ctx.http.authInfo`. + * + * On failure the middleware sends a JSON OAuth error body and a + * `WWW-Authenticate: Bearer …` challenge that includes the configured + * `resource_metadata` URL so clients can discover the Authorization Server. + */ +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header'); + } + + const [type, token] = authHeader.split(' '); + if (type?.toLowerCase() !== 'bearer' || !token) { + throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'"); + } + + const authInfo = await verifier.verifyAccessToken(token); + + // Check if token has the required scopes (if any) + if (requiredScopes.length > 0) { + const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); + if (!hasAllScopes) { + throw new OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope'); + } + } + + // Check if the token is set to expire or if it is expired + if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time'); + } else if (authInfo.expiresAt < Date.now() / 1000) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired'); + } + + req.auth = authInfo; + next(); + } catch (error) { + if (error instanceof OAuthError) { + const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl); + switch (error.code) { + case OAuthErrorCode.InvalidToken: { + res.set('WWW-Authenticate', challenge); + res.status(401).json(error.toResponseObject()); + break; + } + case OAuthErrorCode.InsufficientScope: { + res.set('WWW-Authenticate', challenge); + res.status(403).json(error.toResponseObject()); + break; + } + case OAuthErrorCode.ServerError: { + res.status(500).json(error.toResponseObject()); + break; + } + default: { + res.status(400).json(error.toResponseObject()); + } + } + } else { + const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} diff --git a/packages/middleware/express/src/auth/metadataRouter.ts b/packages/middleware/express/src/auth/metadataRouter.ts new file mode 100644 index 0000000..7913c88 --- /dev/null +++ b/packages/middleware/express/src/auth/metadataRouter.ts @@ -0,0 +1,153 @@ +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { RequestHandler, Router } from 'express'; +import express from 'express'; + +// Dev-only escape hatch: allow http:// issuer URLs (e.g., for local testing). +const allowInsecureIssuerUrl = + process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === 'true' || process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === '1'; +if (allowInsecureIssuerUrl) { + // eslint-disable-next-line no-console + console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.'); +} + +function checkIssuerUrl(issuer: URL): void { + // RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing. + if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) { + throw new Error('Issuer URL must be HTTPS'); + } + if (issuer.hash) { + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); + } + if (issuer.search) { + throw new Error(`Issuer URL must not have a query string: ${issuer}`); + } +} + +/** + * Express middleware that rejects HTTP methods not in the supplied allow-list + * with a 405 Method Not Allowed and an OAuth-style error body. Used by + * {@link metadataHandler} to restrict metadata endpoints to GET/OPTIONS. + */ +export function allowedMethods(allowed: string[]): RequestHandler { + return (req, res, next) => { + if (allowed.includes(req.method)) { + next(); + return; + } + const error = new OAuthError(OAuthErrorCode.MethodNotAllowed, `The method ${req.method} is not allowed for this endpoint`); + res.status(405).set('Allow', allowed.join(', ')).json(error.toResponseObject()); + }; +} + +/** + * Builds a small Express router that serves the given OAuth metadata document + * at `/` as JSON, with permissive CORS and a GET/OPTIONS method allow-list. + * + * Used by {@link mcpAuthMetadataRouter} for both the Authorization Server and + * Protected Resource metadata endpoints. + */ +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { + const router = express.Router(); + // Metadata documents must be fetchable from web-based MCP clients on any origin. + router.use(cors()); + router.use(allowedMethods(['GET', 'OPTIONS'])); + router.get('/', (_req, res) => { + res.status(200).json(metadata); + }); + return router; +} + +/** + * Options for {@link mcpAuthMetadataRouter}. + */ +export interface AuthMetadataOptions { + /** + * Authorization Server metadata (RFC 8414) for the AS this MCP server + * relies on. Served at `/.well-known/oauth-authorization-server` so + * legacy clients that probe the resource origin still discover the AS. + */ + oauthMetadata: OAuthMetadata; + + /** + * The public URL of this MCP server, used as the `resource` value in the + * Protected Resource Metadata document. Any path component is reflected + * in the well-known route per RFC 9728. + */ + resourceServerUrl: URL; + + /** + * Optional documentation URL advertised as `resource_documentation`. + */ + serviceDocumentationUrl?: URL; + + /** + * Optional list of scopes this MCP server understands, advertised as + * `scopes_supported`. + */ + scopesSupported?: string[]; + + /** + * Optional human-readable name advertised as `resource_name`. + */ + resourceName?: string; +} + +/** + * Builds an Express router that serves the two OAuth discovery documents an + * MCP server acting purely as a Resource Server needs to expose: + * + * - `/.well-known/oauth-protected-resource[/]` — RFC 9728 Protected + * Resource Metadata, derived from the supplied options. + * - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization + * Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}. + * + * Mount this router at the application root: + * + * ```ts + * app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl })); + * ``` + * + * Pair with `requireBearerAuth` on your `/mcp` route and pass + * `getOAuthProtectedResourceMetadataUrl` as its `resourceMetadataUrl` + * so unauthenticated clients can discover the AS from the 401 challenge. + */ +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router { + checkIssuerUrl(new URL(options.oauthMetadata.issuer)); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.resourceServerUrl.href, + authorization_servers: [options.oauthMetadata.issuer], + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href + }; + + // Serve PRM at the path-aware URL per RFC 9728 §3.1. + const rsPath = new URL(options.resourceServerUrl.href).pathname; + router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); + + // Mirror the AS metadata at this origin for clients that look here first. + router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata)); + + return router; +} + +/** + * Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server + * URL by inserting `/.well-known/oauth-protected-resource` ahead of the path. + * + * @example + * ```ts + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp' + * ``` + */ +export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { + const u = new URL(serverUrl.href); + const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : ''; + return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href; +} diff --git a/packages/middleware/express/src/auth/types.ts b/packages/middleware/express/src/auth/types.ts new file mode 100644 index 0000000..9d03fe0 --- /dev/null +++ b/packages/middleware/express/src/auth/types.ts @@ -0,0 +1,37 @@ +import type { AuthInfo } from '@modelcontextprotocol/server'; + +/** + * Minimal token-verifier interface for MCP servers acting as an OAuth 2.0 + * Resource Server. Implementations introspect or locally validate an access + * token and return the resulting {@link AuthInfo}, which is then attached to + * the Express request and surfaced to MCP request handlers via + * `ctx.http.authInfo`. + * + * This is intentionally narrower than a full OAuth Authorization Server + * provider — it only covers the verification step a Resource Server needs. + */ +export interface OAuthTokenVerifier { + /** + * Verifies an access token and returns information about it. + * + * Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`) + * with `OAuthErrorCode.InvalidToken` when + * the token is unknown, revoked, or otherwise invalid; `requireBearerAuth` + * maps that to a 401 with a `WWW-Authenticate` challenge. + * + * Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset + * (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662 + * introspection `exp` or the JWT `exp` claim). + */ + verifyAccessToken(token: string): Promise; +} + +declare module 'express-serve-static-core' { + interface Request { + /** + * Information about the validated access token, populated by + * `requireBearerAuth`. + */ + auth?: AuthInfo; + } +} diff --git a/packages/middleware/express/src/express.examples.ts b/packages/middleware/express/src/express.examples.ts new file mode 100644 index 0000000..8d3f8e2 --- /dev/null +++ b/packages/middleware/express/src/express.examples.ts @@ -0,0 +1,41 @@ +/** + * Type-checked examples for `express.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { createMcpExpressApp } from './express.js'; + +/** + * Example: Basic usage with default DNS rebinding protection. + */ +function createMcpExpressApp_default() { + //#region createMcpExpressApp_default + const app = createMcpExpressApp(); + //#endregion createMcpExpressApp_default + return app; +} + +/** + * Example: Custom host binding with and without DNS rebinding protection. + */ +function createMcpExpressApp_customHost() { + //#region createMcpExpressApp_customHost + const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + const appLocal = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + //#endregion createMcpExpressApp_customHost + return { appOpen, appLocal }; +} + +/** + * Example: Custom allowed hosts for non-localhost binding. + */ +function createMcpExpressApp_allowedHosts() { + //#region createMcpExpressApp_allowedHosts + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + //#endregion createMcpExpressApp_allowedHosts + return app; +} diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts new file mode 100644 index 0000000..2525029 --- /dev/null +++ b/packages/middleware/express/src/express.ts @@ -0,0 +1,88 @@ +import type { Express } from 'express'; +import express from 'express'; + +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; + +/** + * Options for creating an MCP Express application. + */ +export interface CreateMcpExpressAppOptions { + /** + * The hostname to bind to. Defaults to `'127.0.0.1'`. + * When set to `'127.0.0.1'`, `'localhost'`, or `'::1'`, DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., `'[::1]'`). + * + * This is useful when binding to `'0.0.0.0'` or `'::'` but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; + + /** + * Controls the maximum request body size for the JSON body parser. + * Passed directly to Express's `express.json({ limit })` option. + * Defaults to Express's built-in default of `'100kb'`. + * + * @example '1mb', '500kb', '10mb' + */ + jsonLimit?: string; +} + +/** + * Creates an Express application pre-configured for MCP servers. + * + * When the host is `'127.0.0.1'`, `'localhost'`, or `'::1'` (the default is `'127.0.0.1'`), + * DNS rebinding protection middleware is automatically applied to protect against + * DNS rebinding attacks on localhost servers. + * + * @param options - Configuration options + * @returns A configured Express application + * + * @example Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * ```ts source="./express.examples.ts#createMcpExpressApp_default" + * const app = createMcpExpressApp(); + * ``` + * + * @example Custom host - DNS rebinding protection only applied for localhost hosts + * ```ts source="./express.examples.ts#createMcpExpressApp_customHost" + * const appOpen = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const appLocal = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * ``` + * + * @example Custom allowed hosts for non-localhost binding + * ```ts source="./express.examples.ts#createMcpExpressApp_allowedHosts" + * const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + * ``` + */ +export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { + const { host = '127.0.0.1', allowedHosts, jsonLimit } = options; + + const app = express(); + app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined)); + + // If allowedHosts is explicitly provided, use that for validation + if (allowedHosts) { + app.use(hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.use(localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection + // eslint-disable-next-line no-console + console.warn( + `Warning: Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/packages/middleware/express/src/index.ts b/packages/middleware/express/src/index.ts new file mode 100644 index 0000000..d2742ce --- /dev/null +++ b/packages/middleware/express/src/index.ts @@ -0,0 +1,9 @@ +export * from './express.js'; +export * from './middleware/hostHeaderValidation.js'; + +// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router. +export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth.js'; +export { requireBearerAuth } from './auth/bearerAuth.js'; +export type { AuthMetadataOptions } from './auth/metadataRouter.js'; +export { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from './auth/metadataRouter.js'; +export type { OAuthTokenVerifier } from './auth/types.js'; diff --git a/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts b/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts new file mode 100644 index 0000000..2e00f48 --- /dev/null +++ b/packages/middleware/express/src/middleware/hostHeaderValidation.examples.ts @@ -0,0 +1,31 @@ +/** + * Type-checked examples for `hostHeaderValidation.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { Express } from 'express'; + +import { hostHeaderValidation, localhostHostValidation } from './hostHeaderValidation.js'; + +/** + * Example: Using hostHeaderValidation middleware with custom allowed hosts. + */ +function hostHeaderValidation_basicUsage(app: Express) { + //#region hostHeaderValidation_basicUsage + const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + app.use(middleware); + //#endregion hostHeaderValidation_basicUsage +} + +/** + * Example: Using localhostHostValidation convenience middleware. + */ +function localhostHostValidation_basicUsage(app: Express) { + //#region localhostHostValidation_basicUsage + app.use(localhostHostValidation()); + //#endregion localhostHostValidation_basicUsage +} diff --git a/packages/middleware/express/src/middleware/hostHeaderValidation.ts b/packages/middleware/express/src/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000..c22ee66 --- /dev/null +++ b/packages/middleware/express/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,52 @@ +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; + +/** + * Express middleware for DNS rebinding protection. + * Validates `Host` header hostname (port-agnostic) against an allowed list. + * + * This is particularly important for servers without authorization or HTTPS, + * such as localhost servers or development servers. DNS rebinding attacks can + * bypass same-origin policy by manipulating DNS to point a domain to a + * localhost address, allowing malicious websites to access your local server. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Express middleware function + * + * @example + * ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidation_basicUsage" + * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * app.use(middleware); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const result = validateHostHeader(req.headers.host, allowedHostnames); + if (!result.ok) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames. + * + * @example + * ```ts source="./hostHeaderValidation.examples.ts#localhostHostValidation_basicUsage" + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation(): RequestHandler { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/middleware/express/test/auth/resourceServer.test.ts b/packages/middleware/express/test/auth/resourceServer.test.ts new file mode 100644 index 0000000..e9ab4b6 --- /dev/null +++ b/packages/middleware/express/test/auth/resourceServer.test.ts @@ -0,0 +1,218 @@ +import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import express from 'express'; +import supertest from 'supertest'; +import type { Mock } from 'vitest'; +import { vi } from 'vitest'; + +import type { OAuthTokenVerifier } from '../../src/auth/types.js'; +import { requireBearerAuth } from '../../src/auth/bearerAuth.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../src/auth/metadataRouter.js'; + +// --------------------------------------------------------------------------- +// requireBearerAuth +// --------------------------------------------------------------------------- + +const mockVerifyAccessToken = vi.fn(); +const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken }; + +function createMockReqResNext(authorization?: string) { + const req = { headers: { authorization } } as Request; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis() + } as unknown as Response; + const next = vi.fn() as Mock; + return { req, res, next }; +} + +describe('requireBearerAuth middleware', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('attaches AuthInfo to req.auth and calls next on a valid token', async () => { + const validAuthInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read', 'write'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }; + mockVerifyAccessToken.mockResolvedValue(validAuthInfo); + + const { req, res, next } = createMockReqResNext('Bearer valid-token'); + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(req, res, next); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token'); + expect(req.auth).toEqual(validAuthInfo); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('responds 401 with WWW-Authenticate (incl. resource_metadata) when header is missing', async () => { + const { req, res, next } = createMockReqResNext(undefined); + const middleware = requireBearerAuth({ + verifier: mockVerifier, + resourceMetadataUrl: 'https://api.example.com/.well-known/oauth-protected-resource' + }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + expect.stringMatching( + /^Bearer error="invalid_token".*resource_metadata="https:\/\/api\.example\.com\/\.well-known\/oauth-protected-resource"$/ + ) + ); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token' })); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 401 when the verifier throws InvalidToken', async () => { + mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token')); + + const { req, res, next } = createMockReqResNext('Bearer nope'); + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'unknown token' })); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 401 when the token is expired', async () => { + mockVerifyAccessToken.mockResolvedValue({ + token: 'expired', + clientId: 'client-123', + scopes: [], + expiresAt: Math.floor(Date.now() / 1000) - 100 + } satisfies AuthInfo); + + const { req, res, next } = createMockReqResNext('Bearer expired'); + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' })); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 403 with scope in WWW-Authenticate when required scopes are missing', async () => { + mockVerifyAccessToken.mockResolvedValue({ + token: 'valid', + clientId: 'client-123', + scopes: ['read'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 + } satisfies AuthInfo); + + const { req, res, next } = createMockReqResNext('Bearer valid'); + const middleware = requireBearerAuth({ verifier: mockVerifier, requiredScopes: ['read', 'write'] }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('scope="read write"')); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'insufficient_scope' })); + expect(next).not.toHaveBeenCalled(); + }); + + it('responds 500 when the verifier throws a non-OAuth error', async () => { + mockVerifyAccessToken.mockRejectedValue(new Error('boom')); + + const { req, res, next } = createMockReqResNext('Bearer valid'); + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' })); + expect(next).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// mcpAuthMetadataRouter + getOAuthProtectedResourceMetadataUrl +// --------------------------------------------------------------------------- + +describe('mcpAuthMetadataRouter', () => { + const oauthMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com/', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + it('serves PRM and AS metadata at the well-known endpoints', async () => { + const app = express(); + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: new URL('https://api.example.com/'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API', + serviceDocumentationUrl: new URL('https://docs.example.com/') + }) + ); + + const prm = await supertest(app).get('/.well-known/oauth-protected-resource'); + expect(prm.status).toBe(200); + expect(prm.body.resource).toBe('https://api.example.com/'); + expect(prm.body.authorization_servers).toEqual(['https://auth.example.com/']); + expect(prm.body.scopes_supported).toEqual(['read', 'write']); + expect(prm.body.resource_name).toBe('Test API'); + expect(prm.body.resource_documentation).toBe('https://docs.example.com/'); + + const as = await supertest(app).get('/.well-known/oauth-authorization-server'); + expect(as.status).toBe(200); + expect(as.body.issuer).toBe('https://auth.example.com/'); + expect(as.body.token_endpoint).toBe('https://auth.example.com/token'); + }); + + it('serves PRM at a path-aware route when resourceServerUrl has a path', async () => { + const app = express(); + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: new URL('https://api.example.com/mcp') + }) + ); + + const prm = await supertest(app).get('/.well-known/oauth-protected-resource/mcp'); + expect(prm.status).toBe(200); + expect(prm.body.resource).toBe('https://api.example.com/mcp'); + }); + + it('rejects non-GET methods on metadata endpoints with 405', async () => { + const app = express(); + app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: new URL('https://api.example.com/') })); + + const res = await supertest(app).post('/.well-known/oauth-protected-resource'); + expect(res.status).toBe(405); + expect(res.headers.allow).toBe('GET, OPTIONS'); + expect(res.body.error).toBe('method_not_allowed'); + }); + + it('rejects non-HTTPS issuer URLs', () => { + expect(() => + mcpAuthMetadataRouter({ + oauthMetadata: { ...oauthMetadata, issuer: 'http://auth.example.com/' }, + resourceServerUrl: new URL('https://api.example.com/') + }) + ).toThrow('Issuer URL must be HTTPS'); + }); +}); + +describe('getOAuthProtectedResourceMetadataUrl', () => { + it('inserts the well-known prefix ahead of the path', () => { + expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))).toBe( + 'https://api.example.com/.well-known/oauth-protected-resource/mcp' + ); + }); + + it('drops a bare root path', () => { + expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/'))).toBe( + 'https://api.example.com/.well-known/oauth-protected-resource' + ); + }); +}); diff --git a/packages/middleware/express/test/express.test.ts b/packages/middleware/express/test/express.test.ts new file mode 100644 index 0000000..f4be9f9 --- /dev/null +++ b/packages/middleware/express/test/express.test.ts @@ -0,0 +1,192 @@ +import type { NextFunction, Request, Response } from 'express'; +import { vi } from 'vitest'; + +import { createMcpExpressApp } from '../src/express.js'; +import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js'; + +// Helper to create mock Express request/response/next +function createMockReqResNext(host?: string) { + const req = { + headers: { + host + } + } as Request; + + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis() + } as unknown as Response; + + const next = vi.fn() as NextFunction; + + return { req, res, next }; +} + +describe('@modelcontextprotocol/express', () => { + describe('hostHeaderValidation', () => { + test('should block invalid Host header', () => { + const middleware = hostHeaderValidation(['localhost']); + const { req, res, next } = createMockReqResNext('evil.com:3000'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + test('should allow valid Host header', () => { + const middleware = hostHeaderValidation(['localhost']); + const { req, res, next } = createMockReqResNext('localhost:3000'); + + middleware(req, res, next); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + test('should handle multiple allowed hostnames', () => { + const middleware = hostHeaderValidation(['localhost', '127.0.0.1', 'myapp.local']); + const { req: req1, res: res1, next: next1 } = createMockReqResNext('127.0.0.1:8080'); + const { req: req2, res: res2, next: next2 } = createMockReqResNext('myapp.local'); + + middleware(req1, res1, next1); + middleware(req2, res2, next2); + + expect(next1).toHaveBeenCalled(); + expect(next2).toHaveBeenCalled(); + }); + }); + + describe('localhostHostValidation', () => { + test('should allow localhost', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('localhost:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow 127.0.0.1', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('127.0.0.1:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow [::1] (IPv6 localhost)', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('[::1]:3000'); + + middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should block non-localhost hosts', () => { + const middleware = localhostHostValidation(); + const { req, res, next } = createMockReqResNext('evil.com:3000'); + + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('createMcpExpressApp', () => { + test('should enable localhost DNS rebinding protection by default', () => { + const app = createMcpExpressApp(); + + // The app should be a valid Express application + expect(app).toBeDefined(); + expect(typeof app.use).toBe('function'); + expect(typeof app.get).toBe('function'); + expect(typeof app.post).toBe('function'); + }); + + test('should apply DNS rebinding protection for localhost host', () => { + const app = createMcpExpressApp({ host: 'localhost' }); + expect(app).toBeDefined(); + }); + + test('should apply DNS rebinding protection for ::1 host', () => { + const app = createMcpExpressApp({ host: '::1' }); + expect(app).toBeDefined(); + }); + + test('should use allowedHosts when provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + warn.mockRestore(); + + expect(app).toBeDefined(); + }); + + test('should warn when binding to 0.0.0.0 without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0' }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection') + ); + + warn.mockRestore(); + }); + + test('should warn when binding to :: without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '::' }); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: Server is binding to :: without DNS rebinding protection')); + + warn.mockRestore(); + }); + + test('should not warn for 0.0.0.0 when allowedHosts is provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + test('should not apply host validation for non-localhost hosts without allowedHosts', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied and no warning + const app = createMcpExpressApp({ host: '192.168.1.1' }); + + expect(warn).not.toHaveBeenCalled(); + expect(app).toBeDefined(); + + warn.mockRestore(); + }); + + test('should accept jsonLimit option', () => { + const app = createMcpExpressApp({ jsonLimit: '10mb' }); + expect(app).toBeDefined(); + }); + + test('should work without jsonLimit option', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + }); + }); +}); diff --git a/packages/middleware/express/tsconfig.json b/packages/middleware/express/tsconfig.json new file mode 100644 index 0000000..0292cb0 --- /dev/null +++ b/packages/middleware/express/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ] + } + } +} diff --git a/packages/middleware/express/tsdown.config.ts b/packages/middleware/express/tsdown.config.ts new file mode 100644 index 0000000..64ed143 --- /dev/null +++ b/packages/middleware/express/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/server': ['../server/src/index.ts'] + } + } + } +}); diff --git a/packages/middleware/express/typedoc.json b/packages/middleware/express/typedoc.json new file mode 100644 index 0000000..dd70079 --- /dev/null +++ b/packages/middleware/express/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/middleware/express/vitest.config.js b/packages/middleware/express/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/middleware/express/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/middleware/fastify/CHANGELOG.md b/packages/middleware/fastify/CHANGELOG.md new file mode 100644 index 0000000..94d0a35 --- /dev/null +++ b/packages/middleware/fastify/CHANGELOG.md @@ -0,0 +1,31 @@ +# @modelcontextprotocol/fastify + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +- Updated dependencies [[`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb)]: + - @modelcontextprotocol/server@2.0.0-alpha.2 + +## 2.0.0-alpha.1 + +### Minor Changes + +- [#1536](https://github.com/modelcontextprotocol/typescript-sdk/pull/1536) [`81e4b2a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/81e4b2a412ab52ada436061502dd711a67555519) Thanks [@andyfleming](https://github.com/andyfleming)! - Add Fastify middleware adapter + for MCP servers, following the same pattern as the Express and Hono adapters. + +### Patch Changes + +- Updated dependencies [[`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333), [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4), + [`3466a9e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3466a9e0e5d392824156d9b290863ae08192d87e), [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47), + [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e), [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2), + [`78bae74`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78bae7426d4ca38216c0571b5aa7806f58ab81e4), [`689148d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/689148dc7235f53244869ed64b2ecc9ec4ef70f1), + [`f1ade75`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f1ade75b67a2b46b06316fa4e5caa1d277537cc7), [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`f66a55b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f66a55b5f4eb7ce0f8b3885633bf9a7b1080e0b5), + [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8), + [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac)]: + - @modelcontextprotocol/server@2.0.0-alpha.1 diff --git a/packages/middleware/fastify/README.md b/packages/middleware/fastify/README.md new file mode 100644 index 0000000..2355170 --- /dev/null +++ b/packages/middleware/fastify/README.md @@ -0,0 +1,70 @@ +# `@modelcontextprotocol/fastify` + +Fastify adapters for the MCP TypeScript server SDK. + +This package is a thin Fastify integration layer for [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/server). + +It does **not** implement MCP itself. Instead, it helps you: + +- create a Fastify app with sensible defaults for MCP servers +- add DNS rebinding protection via Host header validation (recommended for localhost servers) + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/fastify fastify + +# For MCP Streamable HTTP over Node.js (IncomingMessage/ServerResponse): +npm install @modelcontextprotocol/node +``` + +## Exports + +- `createMcpFastifyApp(options?)` +- `hostHeaderValidation(allowedHostnames)` +- `localhostHostValidation()` + +## Usage + +### Create a Fastify app (localhost DNS rebinding protection by default) + +```ts +import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; + +const app = createMcpFastifyApp(); // default host is 127.0.0.1; protection enabled +``` + +### Streamable HTTP endpoint (Fastify) + +```ts +import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; + +const app = createMcpFastifyApp(); +const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' }); + +app.post('/mcp', async (request, reply) => { + // Stateless example: create a transport per request. + // For stateful mode (sessions), keep a transport instance around and reuse it. + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(transport); + + // Clean up when the client closes the connection (e.g. during SSE streaming). + reply.raw.on('close', () => { + transport.close(); + }); + + await transport.handleRequest(request.raw, reply.raw, request.body); +}); +``` + +If you create a new `McpServer` per request in stateless mode, also call `mcpServer.close()` in the `close` handler. To reject non-POST requests with 405 Method Not Allowed, add routes for GET and DELETE that send a JSON-RPC error response. + +### Host header validation (DNS rebinding protection) + +```ts +import { hostHeaderValidation } from '@modelcontextprotocol/fastify'; + +app.addHook('onRequest', hostHeaderValidation(['localhost', '127.0.0.1', '[::1]'])); +``` diff --git a/packages/middleware/fastify/eslint.config.mjs b/packages/middleware/fastify/eslint.config.mjs new file mode 100644 index 0000000..03d5331 --- /dev/null +++ b/packages/middleware/fastify/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/(server|core)' + } + } +]; diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json new file mode 100644 index 0000000..de6df8f --- /dev/null +++ b/packages/middleware/fastify/package.json @@ -0,0 +1,66 @@ +{ + "name": "@modelcontextprotocol/fastify", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "fastify", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "npm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "fastify": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/middleware/fastify/src/fastify.examples.ts b/packages/middleware/fastify/src/fastify.examples.ts new file mode 100644 index 0000000..36353ce --- /dev/null +++ b/packages/middleware/fastify/src/fastify.examples.ts @@ -0,0 +1,41 @@ +/** + * Type-checked examples for `fastify.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { createMcpFastifyApp } from './fastify.js'; + +/** + * Example: Basic usage with default DNS rebinding protection. + */ +function createMcpFastifyApp_default() { + //#region createMcpFastifyApp_default + const app = createMcpFastifyApp(); + //#endregion createMcpFastifyApp_default + return app; +} + +/** + * Example: Custom host binding with and without DNS rebinding protection. + */ +function createMcpFastifyApp_customHost() { + //#region createMcpFastifyApp_customHost + const appOpen = createMcpFastifyApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + const appLocal = createMcpFastifyApp({ host: 'localhost' }); // DNS rebinding protection enabled + //#endregion createMcpFastifyApp_customHost + return { appOpen, appLocal }; +} + +/** + * Example: Custom allowed hosts for non-localhost binding. + */ +function createMcpFastifyApp_allowedHosts() { + //#region createMcpFastifyApp_allowedHosts + const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + //#endregion createMcpFastifyApp_allowedHosts + return app; +} diff --git a/packages/middleware/fastify/src/fastify.ts b/packages/middleware/fastify/src/fastify.ts new file mode 100644 index 0000000..33c03dc --- /dev/null +++ b/packages/middleware/fastify/src/fastify.ts @@ -0,0 +1,82 @@ +import type { FastifyInstance } from 'fastify'; +import Fastify from 'fastify'; + +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; + +/** + * Options for creating an MCP Fastify application. + */ +export interface CreateMcpFastifyAppOptions { + /** + * The hostname to bind to. Defaults to `'127.0.0.1'`. + * When set to `'127.0.0.1'`, `'localhost'`, or `'::1'`, DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., `'[::1]'`). + * + * This is useful when binding to `'0.0.0.0'` or `'::'` but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; +} + +/** + * Creates a Fastify application pre-configured for MCP servers. + * + * When the host is `'127.0.0.1'`, `'localhost'`, or `'::1'` (the default is `'127.0.0.1'`), + * DNS rebinding protection is automatically applied via an onRequest hook to protect against + * DNS rebinding attacks on localhost servers. + * + * Fastify parses JSON request bodies by default, so no additional middleware is required + * for MCP Streamable HTTP endpoints. + * + * @param options - Configuration options + * @returns A configured Fastify application + * + * @example Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * ```ts source="./fastify.examples.ts#createMcpFastifyApp_default" + * const app = createMcpFastifyApp(); + * ``` + * + * @example Custom host - DNS rebinding protection only applied for localhost hosts + * ```ts source="./fastify.examples.ts#createMcpFastifyApp_customHost" + * const appOpen = createMcpFastifyApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const appLocal = createMcpFastifyApp({ host: 'localhost' }); // DNS rebinding protection enabled + * ``` + * + * @example Custom allowed hosts for non-localhost binding + * ```ts source="./fastify.examples.ts#createMcpFastifyApp_allowedHosts" + * const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + * ``` + */ +export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): FastifyInstance { + const { host = '127.0.0.1', allowedHosts } = options; + + const app = Fastify(); + + // Fastify parses JSON by default - no middleware needed + + // If allowedHosts is explicitly provided, use that for validation + if (allowedHosts) { + app.addHook('onRequest', hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.addHook('onRequest', localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection + app.log.warn( + `Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/packages/middleware/fastify/src/index.ts b/packages/middleware/fastify/src/index.ts new file mode 100644 index 0000000..5c85261 --- /dev/null +++ b/packages/middleware/fastify/src/index.ts @@ -0,0 +1,2 @@ +export * from './fastify.js'; +export * from './middleware/hostHeaderValidation.js'; diff --git a/packages/middleware/fastify/src/middleware/hostHeaderValidation.examples.ts b/packages/middleware/fastify/src/middleware/hostHeaderValidation.examples.ts new file mode 100644 index 0000000..cbf6645 --- /dev/null +++ b/packages/middleware/fastify/src/middleware/hostHeaderValidation.examples.ts @@ -0,0 +1,30 @@ +/** + * Type-checked examples for `hostHeaderValidation.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { FastifyInstance } from 'fastify'; + +import { hostHeaderValidation, localhostHostValidation } from './hostHeaderValidation.js'; + +/** + * Example: Using hostHeaderValidation hook with custom allowed hosts. + */ +function hostHeaderValidation_basicUsage(app: FastifyInstance) { + //#region hostHeaderValidation_basicUsage + app.addHook('onRequest', hostHeaderValidation(['localhost', '127.0.0.1', '[::1]'])); + //#endregion hostHeaderValidation_basicUsage +} + +/** + * Example: Using localhostHostValidation convenience hook. + */ +function localhostHostValidation_basicUsage(app: FastifyInstance) { + //#region localhostHostValidation_basicUsage + app.addHook('onRequest', localhostHostValidation()); + //#endregion localhostHostValidation_basicUsage +} diff --git a/packages/middleware/fastify/src/middleware/hostHeaderValidation.ts b/packages/middleware/fastify/src/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000..41b3b24 --- /dev/null +++ b/packages/middleware/fastify/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,49 @@ +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +/** + * Fastify onRequest hook for DNS rebinding protection. + * Validates `Host` header hostname (port-agnostic) against an allowed list. + * + * This is particularly important for servers without authorization or HTTPS, + * such as localhost servers or development servers. DNS rebinding attacks can + * bypass same-origin policy by manipulating DNS to point a domain to a + * localhost address, allowing malicious websites to access your local server. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., `[::1]`). + * @returns Fastify onRequest hook handler + * + * @example + * ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidation_basicUsage" + * app.addHook('onRequest', hostHeaderValidation(['localhost', '127.0.0.1', '[::1]'])); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + const result = validateHostHeader(request.headers.host, allowedHostnames); + if (!result.ok) { + await reply.code(403).send({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }); + } + }; +} + +/** + * Convenience hook for localhost DNS rebinding protection. + * Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames. + * + * @example + * ```ts source="./hostHeaderValidation.examples.ts#localhostHostValidation_basicUsage" + * app.addHook('onRequest', localhostHostValidation()); + * ``` + */ +export function localhostHostValidation() { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/middleware/fastify/test/fastify.test.ts b/packages/middleware/fastify/test/fastify.test.ts new file mode 100644 index 0000000..a64e920 --- /dev/null +++ b/packages/middleware/fastify/test/fastify.test.ts @@ -0,0 +1,209 @@ +import Fastify from 'fastify'; + +import { createMcpFastifyApp } from '../src/fastify.js'; +import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js'; + +describe('@modelcontextprotocol/fastify', () => { + describe('hostHeaderValidation', () => { + test('should block invalid Host header', async () => { + const app = Fastify(); + app.addHook('onRequest', hostHeaderValidation(['localhost'])); + app.get('/health', async () => ({ ok: true })); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'evil.com:3000' } + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + }); + + test('should allow valid Host header', async () => { + const app = Fastify(); + app.addHook('onRequest', hostHeaderValidation(['localhost'])); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000' } + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe('ok'); + }); + + test('should handle multiple allowed hostnames', async () => { + const app = Fastify(); + app.addHook('onRequest', hostHeaderValidation(['localhost', '127.0.0.1', 'myapp.local'])); + app.get('/health', async () => 'ok'); + + const res1 = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: '127.0.0.1:8080' } + }); + const res2 = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local' } + }); + + expect(res1.statusCode).toBe(200); + expect(res2.statusCode).toBe(200); + }); + }); + + describe('localhostHostValidation', () => { + test('should allow localhost', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostHostValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000' } + }); + expect(res.statusCode).toBe(200); + }); + + test('should allow 127.0.0.1', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostHostValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: '127.0.0.1:3000' } + }); + expect(res.statusCode).toBe(200); + }); + + test('should allow [::1] (IPv6 localhost)', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostHostValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: '[::1]:3000' } + }); + expect(res.statusCode).toBe(200); + }); + + test('should block non-localhost hosts', async () => { + const app = Fastify(); + app.addHook('onRequest', localhostHostValidation()); + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'evil.com:3000' } + }); + expect(res.statusCode).toBe(403); + }); + }); + + describe('createMcpFastifyApp', () => { + test('should enable localhost DNS rebinding protection by default', async () => { + const app = createMcpFastifyApp(); + app.get('/health', async () => 'ok'); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'evil.com:3000' } + }); + expect(bad.statusCode).toBe(403); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'localhost:3000' } + }); + expect(good.statusCode).toBe(200); + }); + + test('should apply DNS rebinding protection for localhost host', () => { + const app = createMcpFastifyApp({ host: 'localhost' }); + expect(app).toBeDefined(); + expect(typeof app.addHook).toBe('function'); + expect(typeof app.get).toBe('function'); + expect(typeof app.post).toBe('function'); + }); + + test('should apply DNS rebinding protection for ::1 host', () => { + const app = createMcpFastifyApp({ host: '::1' }); + expect(app).toBeDefined(); + }); + + test('should use allowedHosts when provided', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + + app.get('/health', async () => 'ok'); + + const bad = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'evil.com:3000' } + }); + expect(bad.statusCode).toBe(403); + + const good = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'myapp.local:3000' } + }); + expect(good.statusCode).toBe(200); + }); + + test('should log warning when binding to 0.0.0.0 without allowedHosts', () => { + const app = createMcpFastifyApp({ host: '0.0.0.0' }); + expect(app).toBeDefined(); + expect(app.log).toBeDefined(); + }); + + test('should log warning when binding to :: without allowedHosts', () => { + const app = createMcpFastifyApp({ host: '::' }); + expect(app).toBeDefined(); + expect(app.log).toBeDefined(); + }); + + test('should not log warning for 0.0.0.0 when allowedHosts is provided', () => { + const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + expect(app).toBeDefined(); + }); + + test('should not apply host validation for 0.0.0.0 without allowedHosts', async () => { + const app = createMcpFastifyApp({ host: '0.0.0.0' }); + + app.get('/health', async () => 'ok'); + + const res = await app.inject({ + method: 'GET', + url: '/health', + headers: { host: 'evil.com:3000' } + }); + expect(res.statusCode).toBe(200); + }); + + test('should not apply host validation for non-localhost hosts without allowedHosts', () => { + const app = createMcpFastifyApp({ host: '192.168.1.1' }); + expect(app).toBeDefined(); + }); + }); +}); diff --git a/packages/middleware/fastify/tsconfig.json b/packages/middleware/fastify/tsconfig.json new file mode 100644 index 0000000..c924358 --- /dev/null +++ b/packages/middleware/fastify/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ] + } + } +} diff --git a/packages/middleware/fastify/tsdown.config.ts b/packages/middleware/fastify/tsdown.config.ts new file mode 100644 index 0000000..64ed143 --- /dev/null +++ b/packages/middleware/fastify/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/server': ['../server/src/index.ts'] + } + } + } +}); diff --git a/packages/middleware/fastify/typedoc.json b/packages/middleware/fastify/typedoc.json new file mode 100644 index 0000000..dd70079 --- /dev/null +++ b/packages/middleware/fastify/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/middleware/fastify/vitest.config.js b/packages/middleware/fastify/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/middleware/fastify/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md new file mode 100644 index 0000000..7a9b246 --- /dev/null +++ b/packages/middleware/hono/CHANGELOG.md @@ -0,0 +1,31 @@ +# @modelcontextprotocol/hono + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +- Updated dependencies [[`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb)]: + - @modelcontextprotocol/server@2.0.0-alpha.2 + +## 2.0.0-alpha.1 + +### Patch Changes + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- Updated dependencies [[`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333), [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4), + [`3466a9e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3466a9e0e5d392824156d9b290863ae08192d87e), [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47), + [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e), [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2), + [`78bae74`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78bae7426d4ca38216c0571b5aa7806f58ab81e4), [`689148d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/689148dc7235f53244869ed64b2ecc9ec4ef70f1), + [`f1ade75`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f1ade75b67a2b46b06316fa4e5caa1d277537cc7), [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`f66a55b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f66a55b5f4eb7ce0f8b3885633bf9a7b1080e0b5), + [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8), + [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac)]: + - @modelcontextprotocol/server@2.0.0-alpha.1 diff --git a/packages/middleware/hono/README.md b/packages/middleware/hono/README.md new file mode 100644 index 0000000..f591888 --- /dev/null +++ b/packages/middleware/hono/README.md @@ -0,0 +1,48 @@ +# `@modelcontextprotocol/hono` + +Hono adapters for the MCP TypeScript server SDK. + +This package is a thin Hono integration layer for [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/server). + +It does **not** implement MCP itself. Instead, it helps you: + +- create a Hono app with sensible defaults for MCP servers +- parse JSON request bodies and expose them as `c.get('parsedBody')` for Streamable HTTP transports +- add DNS rebinding protection via Host header validation (recommended for localhost servers) + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono +``` + +## Exports + +- `createMcpHonoApp(options?)` +- `hostHeaderValidation(allowedHostnames)` +- `localhostHostValidation()` + +## Usage + +### Streamable HTTP endpoint (Hono) + +```ts +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { createMcpHonoApp } from '@modelcontextprotocol/hono'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); +await server.connect(transport); + +const app = createMcpHonoApp(); +app.all('/mcp', c => transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') })); +``` + +### Host header validation (DNS rebinding protection) + +```ts +import { localhostHostValidation } from '@modelcontextprotocol/hono'; + +const app = createMcpHonoApp(); +app.use('*', localhostHostValidation()); +``` diff --git a/packages/middleware/hono/eslint.config.mjs b/packages/middleware/hono/eslint.config.mjs new file mode 100644 index 0000000..03d5331 --- /dev/null +++ b/packages/middleware/hono/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/(server|core)' + } + } +]; diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json new file mode 100644 index 0000000..f067aed --- /dev/null +++ b/packages/middleware/hono/package.json @@ -0,0 +1,66 @@ +{ + "name": "@modelcontextprotocol/hono", + "private": false, + "version": "2.0.0-alpha.2", + "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "hono", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": {}, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts new file mode 100644 index 0000000..eda3e5d --- /dev/null +++ b/packages/middleware/hono/src/hono.ts @@ -0,0 +1,90 @@ +import type { Context } from 'hono'; +import { Hono } from 'hono'; + +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; + +/** + * Options for creating an MCP Hono application. + */ +export interface CreateMcpHonoAppOptions { + /** + * The hostname to bind to. Defaults to `'127.0.0.1'`. + * When set to `'127.0.0.1'`, `'localhost'`, or `'::1'`, DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., '[::1]'). + * + * This is useful when binding to '0.0.0.0' or '::' but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; +} + +/** + * Creates a Hono application pre-configured for MCP servers. + * + * When the host is `'127.0.0.1'`, `'localhost'`, or `'::1'` (the default is `'127.0.0.1'`), + * DNS rebinding protection middleware is automatically applied to protect against + * DNS rebinding attacks on localhost servers. + * + * This also installs a small JSON body parsing middleware (similar to `express.json()`) + * that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes + * `application/json`. + * + * @param options - Configuration options + * @returns A configured Hono application + */ +export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono { + const { host = '127.0.0.1', allowedHosts } = options; + + const app = new Hono(); + + // Similar to `express.json()`: parse JSON bodies and make them available to MCP adapters via `parsedBody`. + app.use('*', async (c: Context, next) => { + // If an upstream middleware already set parsedBody, keep it. + if (c.get('parsedBody') !== undefined) { + return await next(); + } + + const ct = c.req.header('content-type') ?? ''; + if (!ct.includes('application/json')) { + return await next(); + } + + try { + // Parse from a clone so we don't consume the original request stream. + const parsed = await c.req.raw.clone().json(); + c.set('parsedBody', parsed); + } catch { + // Mirror express.json() behavior loosely: reject invalid JSON. + return c.text('Invalid JSON', 400); + } + + return await next(); + }); + + // If allowedHosts is explicitly provided, use that for validation. + if (allowedHosts) { + app.use('*', hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts. + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.use('*', localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection. + // eslint-disable-next-line no-console + console.warn( + `Warning: Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/packages/middleware/hono/src/index.ts b/packages/middleware/hono/src/index.ts new file mode 100644 index 0000000..a8c65a2 --- /dev/null +++ b/packages/middleware/hono/src/index.ts @@ -0,0 +1,2 @@ +export * from './hono.js'; +export * from './middleware/hostHeaderValidation.js'; diff --git a/packages/middleware/hono/src/middleware/hostHeaderValidation.ts b/packages/middleware/hono/src/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000..00ec293 --- /dev/null +++ b/packages/middleware/hono/src/middleware/hostHeaderValidation.ts @@ -0,0 +1,33 @@ +import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server'; +import type { MiddlewareHandler } from 'hono'; + +/** + * Hono middleware for DNS rebinding protection. + * Validates `Host` header hostname (port-agnostic) against an allowed list. + */ +export function hostHeaderValidation(allowedHostnames: string[]): MiddlewareHandler { + return async (c, next) => { + const result = validateHostHeader(c.req.header('host'), allowedHostnames); + if (!result.ok) { + return c.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + 403 + ); + } + return await next(); + }; +} + +/** + * Convenience middleware for `localhost` DNS rebinding protection. + */ +export function localhostHostValidation(): MiddlewareHandler { + return hostHeaderValidation(localhostAllowedHostnames()); +} diff --git a/packages/middleware/hono/test/hono.test.ts b/packages/middleware/hono/test/hono.test.ts new file mode 100644 index 0000000..a080f1f --- /dev/null +++ b/packages/middleware/hono/test/hono.test.ts @@ -0,0 +1,109 @@ +import type { Context } from 'hono'; +import { Hono } from 'hono'; +import { vi } from 'vitest'; + +import { createMcpHonoApp } from '../src/hono.js'; +import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js'; + +describe('@modelcontextprotocol/hono', () => { + test('hostHeaderValidation blocks invalid Host and allows valid Host', async () => { + const app = new Hono(); + app.use('*', hostHeaderValidation(['localhost'])); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + expect(await bad.json()).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: -32_000 + }), + id: null + }) + ); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(good.status).toBe(200); + expect(await good.text()).toBe('ok'); + }); + + test('createMcpHonoApp enables localhost DNS rebinding protection by default', async () => { + const app = createMcpHonoApp(); + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + + const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } }); + expect(good.status).toBe(200); + }); + + test('createMcpHonoApp uses allowedHosts when provided (even when binding to 0.0.0.0)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] }); + warn.mockRestore(); + + app.get('/health', c => c.text('ok')); + + const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(bad.status).toBe(403); + + const good = await app.request('http://localhost/health', { headers: { Host: 'myapp.local:3000' } }); + expect(good.status).toBe(200); + }); + + test('createMcpHonoApp does not apply host validation for 0.0.0.0 without allowedHosts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpHonoApp({ host: '0.0.0.0' }); + warn.mockRestore(); + + app.get('/health', c => c.text('ok')); + + const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } }); + expect(res.status).toBe(200); + }); + + test('createMcpHonoApp parses JSON bodies into parsedBody (express.json()-like)', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ a: 1 }); + }); + + test('createMcpHonoApp returns 400 on invalid JSON', async () => { + const app = createMcpHonoApp(); + app.post('/echo', (c: Context) => c.text('ok')); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: '{"a":' + }); + expect(res.status).toBe(400); + expect(await res.text()).toBe('Invalid JSON'); + }); + + test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => { + const app = createMcpHonoApp(); + app.use('/echo', async (c: Context, next) => { + c.set('parsedBody', { preset: true }); + return await next(); + }); + app.post('/echo', (c: Context) => c.json(c.get('parsedBody'))); + + const res = await app.request('http://localhost/echo', { + method: 'POST', + headers: { Host: 'localhost:3000', 'content-type': 'application/json' }, + body: JSON.stringify({ a: 1 }) + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ preset: true }); + }); +}); diff --git a/packages/middleware/hono/tsconfig.json b/packages/middleware/hono/tsconfig.json new file mode 100644 index 0000000..0292cb0 --- /dev/null +++ b/packages/middleware/hono/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/core": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts" + ], + "@modelcontextprotocol/core/public": [ + "./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/exports/public/index.ts" + ] + } + } +} diff --git a/packages/middleware/hono/tsdown.config.ts b/packages/middleware/hono/tsdown.config.ts new file mode 100644 index 0000000..64ed143 --- /dev/null +++ b/packages/middleware/hono/tsdown.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + entry: ['src/index.ts'], + format: ['esm'], + outDir: 'dist', + clean: true, + sourcemap: true, + target: 'esnext', + platform: 'node', + shims: true, + dts: { + resolver: 'tsc', + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/server': ['../server/src/index.ts'] + } + } + } +}); diff --git a/packages/middleware/hono/typedoc.json b/packages/middleware/hono/typedoc.json new file mode 100644 index 0000000..dd70079 --- /dev/null +++ b/packages/middleware/hono/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/middleware/hono/vitest.config.js b/packages/middleware/hono/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/middleware/hono/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md new file mode 100644 index 0000000..242614d --- /dev/null +++ b/packages/middleware/node/CHANGELOG.md @@ -0,0 +1,43 @@ +# @modelcontextprotocol/node + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +- Updated dependencies [[`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb)]: + - @modelcontextprotocol/server@2.0.0-alpha.2 + +## 2.0.0-alpha.1 + +### Patch Changes + +- [#1504](https://github.com/modelcontextprotocol/typescript-sdk/pull/1504) [`327243c`](https://github.com/modelcontextprotocol/typescript-sdk/commit/327243cebd96e07686c88f7fa9ca22a5a7a7993d) Thanks [@corvid-agent](https://github.com/corvid-agent)! - Add missing `hono` peer + dependency to `@modelcontextprotocol/node`. The package already depends on `@hono/node-server` which requires `hono` at runtime, but `hono` was only listed in the workspace root, not as a peer dependency of the package itself. + +- [#1410](https://github.com/modelcontextprotocol/typescript-sdk/pull/1410) [`9296459`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9296459ac006546499f6b4105ffc528b8c212d88) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Prevent Hono from overriding + global Response object by passing `overrideGlobalObjects: false` to `getRequestListener()`. This fixes compatibility with frameworks like Next.js whose response classes extend the native Response. + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - remove deprecated .tool, + .prompt, .resource method signatures + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - deprecated .tool, .prompt, + .resource method removal + +- Updated dependencies [[`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333), [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4), + [`3466a9e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3466a9e0e5d392824156d9b290863ae08192d87e), [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47), + [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e), [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2), + [`78bae74`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78bae7426d4ca38216c0571b5aa7806f58ab81e4), [`689148d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/689148dc7235f53244869ed64b2ecc9ec4ef70f1), + [`f1ade75`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f1ade75b67a2b46b06316fa4e5caa1d277537cc7), [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`f66a55b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f66a55b5f4eb7ce0f8b3885633bf9a7b1080e0b5), + [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e), + [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a), [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8), + [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac)]: + - @modelcontextprotocol/server@2.0.0-alpha.1 diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md new file mode 100644 index 0000000..fe10c9f --- /dev/null +++ b/packages/middleware/node/README.md @@ -0,0 +1,55 @@ +# `@modelcontextprotocol/node` + +Node.js adapters for the MCP TypeScript server SDK. + +This package is a thin Node.js integration layer for [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/server). It provides a Streamable HTTP transport that works with Node’s `IncomingMessage` / `ServerResponse`. + +For web‑standard runtimes (Cloudflare Workers, Deno, Bun, etc.), use `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` directly. + +## Install + +```bash +npm install @modelcontextprotocol/server @modelcontextprotocol/node +``` + +## Exports + +- `NodeStreamableHTTPServerTransport` +- `StreamableHTTPServerTransportOptions` (type alias for `WebStandardStreamableHTTPServerTransportOptions`) + +## Usage + +### Express + Streamable HTTP + +```ts +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); +const app = createMcpExpressApp(); + +app.post('/mcp', async (req, res) => { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + + // If you use Express JSON parsing, pass the pre-parsed body to avoid re-reading the stream. + await transport.handleRequest(req, res, req.body); +}); +``` + +### Node.js `http` server + +```ts +import { createServer } from 'node:http'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { McpServer } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + +createServer(async (req, res) => { + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res); +}).listen(3000); +``` diff --git a/packages/middleware/node/eslint.config.mjs b/packages/middleware/node/eslint.config.mjs new file mode 100644 index 0000000..4f034f2 --- /dev/null +++ b/packages/middleware/node/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/core' + } + } +]; diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json new file mode 100644 index 0000000..30fa7ed --- /dev/null +++ b/packages/middleware/node/package.json @@ -0,0 +1,84 @@ +{ + "name": "@modelcontextprotocol/node", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "node.js", + "middleware" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "sse": [ + "dist/sse.d.mts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "@hono/node-server": "catalog:runtimeServerOnly" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "hono": "catalog:runtimeServerOnly" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + }, + "devDependencies": { + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@eslint/js": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts new file mode 100644 index 0000000..2e0d3c9 --- /dev/null +++ b/packages/middleware/node/src/index.ts @@ -0,0 +1 @@ +export * from './streamableHttp.js'; diff --git a/packages/middleware/node/src/streamableHttp.examples.ts b/packages/middleware/node/src/streamableHttp.examples.ts new file mode 100644 index 0000000..fb4bef8 --- /dev/null +++ b/packages/middleware/node/src/streamableHttp.examples.ts @@ -0,0 +1,56 @@ +/** + * Type-checked examples for `streamableHttp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { McpServer } from '@modelcontextprotocol/server'; + +import { NodeStreamableHTTPServerTransport } from './streamableHttp.js'; + +/** + * Example: Stateful Streamable HTTP transport (Node.js). + */ +async function NodeStreamableHTTPServerTransport_stateful() { + //#region NodeStreamableHTTPServerTransport_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await server.connect(transport); + //#endregion NodeStreamableHTTPServerTransport_stateful +} + +/** + * Example: Stateless Streamable HTTP transport (Node.js). + */ +async function NodeStreamableHTTPServerTransport_stateless() { + //#region NodeStreamableHTTPServerTransport_stateless + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + //#endregion NodeStreamableHTTPServerTransport_stateless + return transport; +} + +// Stubs for Express-style app +declare const app: { post(path: string, handler: (req: IncomingMessage & { body?: unknown }, res: ServerResponse) => void): void }; + +/** + * Example: Using with a pre-parsed request body (e.g. Express). + */ +function NodeStreamableHTTPServerTransport_express(transport: NodeStreamableHTTPServerTransport) { + //#region NodeStreamableHTTPServerTransport_express + app.post('/mcp', (req, res) => { + transport.handleRequest(req, res, req.body); + }); + //#endregion NodeStreamableHTTPServerTransport_express +} diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts new file mode 100644 index 0000000..68a0c22 --- /dev/null +++ b/packages/middleware/node/src/streamableHttp.ts @@ -0,0 +1,204 @@ +/** + * Node.js Streamable HTTP Server Transport + * + * This is a thin wrapper around {@linkcode WebStandardStreamableHTTPServerTransport} that provides + * compatibility with Node.js HTTP server (`IncomingMessage`/`ServerResponse`). + * + * For web-standard environments (Cloudflare Workers, Deno, Bun), use {@linkcode WebStandardStreamableHTTPServerTransport} directly. + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { getRequestListener } from '@hono/node-server'; +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +/** + * Configuration options for {@linkcode NodeStreamableHTTPServerTransport} + * + * This is an alias for {@linkcode WebStandardStreamableHTTPServerTransportOptions} for backward compatibility. + */ +export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions; + +/** + * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It supports both SSE streaming and direct HTTP responses. + * + * This is a wrapper around {@linkcode WebStandardStreamableHTTPServerTransport} that provides Node.js HTTP compatibility. + * It uses the `@hono/node-server` library to convert between Node.js HTTP and Web Standard APIs. + * + * In stateful mode: + * - Session ID is generated and included in response headers + * - Session ID is always included in initialization responses + * - Requests with invalid session IDs are rejected with `404 Not Found` + * - Non-initialization requests without a session ID are rejected with `400 Bad Request` + * - State is maintained in-memory (connections, message history) + * + * In stateless mode: + * - No Session ID is included in any responses + * - No session validation is performed + * + * @example Stateful setup + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_stateful" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * + * const transport = new NodeStreamableHTTPServerTransport({ + * sessionIdGenerator: () => randomUUID() + * }); + * + * await server.connect(transport); + * ``` + * + * @example Stateless setup + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_stateless" + * const transport = new NodeStreamableHTTPServerTransport({ + * sessionIdGenerator: undefined + * }); + * ``` + * + * @example Using with a pre-parsed request body (e.g. Express) + * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_express" + * app.post('/mcp', (req, res) => { + * transport.handleRequest(req, res, req.body); + * }); + * ``` + */ +export class NodeStreamableHTTPServerTransport implements Transport { + private _webStandardTransport: WebStandardStreamableHTTPServerTransport; + private _requestListener: ReturnType; + // Store auth and parsedBody per request for passing through to handleRequest + private _requestContext: WeakMap = new WeakMap(); + + constructor(options: StreamableHTTPServerTransportOptions = {}) { + this._webStandardTransport = new WebStandardStreamableHTTPServerTransport(options); + + // Create a request listener that wraps the web standard transport + // getRequestListener converts Node.js HTTP to Web Standard and properly handles SSE streaming + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + this._requestListener = getRequestListener( + async (webRequest: Request) => { + // Get context if available (set during handleRequest) + const context = this._requestContext.get(webRequest); + return this._webStandardTransport.handleRequest(webRequest, { + authInfo: context?.authInfo, + parsedBody: context?.parsedBody + }); + }, + { overrideGlobalObjects: false } + ); + } + + /** + * Gets the session ID for this transport instance. + */ + get sessionId(): string | undefined { + return this._webStandardTransport.sessionId; + } + + /** + * Sets callback for when the transport is closed. + */ + set onclose(handler: (() => void) | undefined) { + this._webStandardTransport.onclose = handler; + } + + get onclose(): (() => void) | undefined { + return this._webStandardTransport.onclose; + } + + /** + * Sets callback for transport errors. + */ + set onerror(handler: ((error: Error) => void) | undefined) { + this._webStandardTransport.onerror = handler; + } + + get onerror(): ((error: Error) => void) | undefined { + return this._webStandardTransport.onerror; + } + + /** + * Sets callback for incoming messages. + */ + set onmessage(handler: ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined) { + this._webStandardTransport.onmessage = handler; + } + + get onmessage(): ((message: JSONRPCMessage, extra?: MessageExtraInfo) => void) | undefined { + return this._webStandardTransport.onmessage; + } + + /** + * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op + * for the Streamable HTTP transport as connections are managed per-request. + */ + async start(): Promise { + return this._webStandardTransport.start(); + } + + /** + * Closes the transport and all active connections. + */ + async close(): Promise { + return this._webStandardTransport.close(); + } + + /** + * Sends a JSON-RPC message through the transport. + */ + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { + return this._webStandardTransport.send(message, options); + } + + /** + * Handles an incoming HTTP request, whether `GET` or `POST`. + * + * This method converts Node.js HTTP objects to Web Standard Request/Response + * and delegates to the underlying {@linkcode WebStandardStreamableHTTPServerTransport}. + * + * @param req - Node.js `IncomingMessage`, optionally with `auth` property from middleware + * @param res - Node.js `ServerResponse` + * @param parsedBody - Optional pre-parsed body from body-parser middleware + */ + async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + // Store context for this request to pass through auth and parsedBody + // We need to intercept the request creation to attach this context + const authInfo = req.auth; + + // Create a custom handler that includes our context + // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would + // break frameworks like Next.js whose response classes extend the native Response + const handler = getRequestListener( + async (webRequest: Request) => { + return this._webStandardTransport.handleRequest(webRequest, { + authInfo, + parsedBody + }); + }, + { overrideGlobalObjects: false } + ); + + // Delegate to the request listener which handles all the Node.js <-> Web Standard conversion + // including proper SSE streaming support + await handler(req, res); + } + + /** + * Close an SSE stream for a specific request, triggering client reconnection. + * Use this to implement polling behavior during long-running operations - + * client will reconnect after the retry interval specified in the priming event. + */ + closeSSEStream(requestId: RequestId): void { + this._webStandardTransport.closeSSEStream(requestId); + } + + /** + * Close the standalone GET SSE stream, triggering client reconnection. + * Use this to implement polling behavior for server-initiated notifications. + */ + closeStandaloneSSEStream(): void { + this._webStandardTransport.closeStandaloneSSEStream(); + } +} diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts new file mode 100644 index 0000000..c427aa2 --- /dev/null +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -0,0 +1,3130 @@ +import { randomUUID } from 'node:crypto'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { createServer as netCreateServer } from 'node:net'; + +import type { + AuthInfo, + CallToolResult, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCResultResponse, + RequestId +} from '@modelcontextprotocol/core'; +import type { EventId, EventStore, StreamId } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import * as z from 'zod/v4'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { NodeStreamableHTTPServerTransport } from '../src/streamableHttp.js'; + +async function getFreePort() { + return new Promise(res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()!; + if (typeof address === 'string') { + throw new TypeError('Unexpected address type: ' + typeof address); + } + const port = (address as AddressInfo).port; + srv.close(_err => res(port)); + }); + }); +} + +/** + * Test server configuration for NodeStreamableHTTPServerTransport tests + */ +interface TestServerConfig { + sessionIdGenerator: (() => string) | undefined; + enableJsonResponse?: boolean; + customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; + eventStore?: EventStore; + onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; + onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + retryInterval?: number; +} + +/** + * Helper to stop test server + */ +async function stopTestServer({ server, transport }: { server: Server; transport: NodeStreamableHTTPServerTransport }): Promise { + // First close the transport to ensure all SSE streams are closed + await transport.close(); + + // Close the server without waiting indefinitely + server.close(); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-11-25', + capabilities: {} + }, + id: 'init-1' + } as JSONRPCMessage, + + // Initialize message with an older protocol version for backward compatibility tests + initializeOldVersion: { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-06-18', + capabilities: {} + }, + id: 'init-1' + } as JSONRPCMessage, + + toolsList: { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'tools-1' + } as JSONRPCMessage +}; + +/** + * Helper to extract text from SSE response + * Note: Can only be called once per response stream. For multiple reads, + * get the reader manually and read multiple times. + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to send JSON-RPC request + */ +async function sendPostRequest( + baseUrl: URL, + message: JSONRPCMessage | JSONRPCMessage[], + sessionId?: string, + extraHeaders?: Record +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...extraHeaders + }; + + if (sessionId) { + headers['mcp-session-id'] = sessionId; + headers['mcp-protocol-version'] = '2025-11-25'; + } + + return fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify(message) + }); +} + +function expectErrorResponse( + data: unknown, + expectedCode: number, + expectedMessagePattern: RegExp, + options?: { expectData?: boolean } +): void { + expect(data).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern) + }) + }); + if (options?.expectData) { + expect((data as { error: { data?: string } }).error.data).toBeDefined(); + } +} +describe('Zod v4', () => { + /** + * Helper to create and start test HTTP server with MCP setup + */ + async function createTestServer(config?: TestServerConfig): Promise<{ + server: Server; + transport: NodeStreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + config ??= { sessionIdGenerator: () => randomUUID() }; + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'greet', + { + description: 'A simple greeting tool', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed, + retryInterval: config.retryInterval + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + await (config.customRequestHandler ? config.customRequestHandler(req, res) : transport.handleRequest(req, res)); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await listenOnRandomPort(server); + + return { server, transport, mcpServer, baseUrl }; + } + + /** + * Helper to create and start authenticated test HTTP server with MCP setup + */ + async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ + server: Server; + transport: NodeStreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'profile', + { + description: 'A user profile data tool', + inputSchema: z.object({ active: z.boolean().describe('Profile status') }) + }, + async ({ active }, ctx): Promise => { + return { + content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${ctx.http?.authInfo?.token}!` }] + }; + } + ); + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed + }); + + await mcpServer.connect(transport); + + const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; + await transport.handleRequest(req, res); + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); + + const baseUrl = await listenOnRandomPort(server); + + return { server, transport, mcpServer, baseUrl }; + } + + describe('NodeStreamableHTTPServerTransport', () => { + let server: Server; + let mcpServer: McpServer; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + mcpServer = result.mcpServer; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it('should initialize server and generate session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('mcp-session-id')).toBeDefined(); + }); + + it('should reject second initialization request', async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); + + // Try second initialize + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: 'second-init' + }; + + const response = await sendPostRequest(baseUrl, secondInitMessage); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_600, /Server already initialized/); + }); + + it('should reject batch initialize request', async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client-2', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-2' + } + ]; + + const response = await sendPostRequest(baseUrl, batchInitMessages); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_600, /Only one initialization request is allowed/); + }); + + it('should handle post requests via sse response correctly', async () => { + sessionId = await initializeServer(); + + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + + // Read the SSE stream for the response + const text = await readSSEEvent(response); + + // Parse the SSE event + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'greet', + description: 'A simple greeting tool' + }) + ]) + }), + id: 'tools-1' + }); + }); + + it('should call a tool and return the result', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + } + ] + }, + id: 'call-1' + }); + }); + + /*** + * Test: Tool With Request Info + */ + it('should expose the full Request object to tool handlers', async () => { + sessionId = await initializeServer(); + + mcpServer.registerTool( + 'test-request-info', + { + description: 'A simple test tool with request info', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }, ctx): Promise => { + const req = ctx.http?.req; + const serializedRequestInfo = { + headers: Object.fromEntries(req?.headers ?? new Headers()), + url: req?.url, + method: req?.method + }; + return { + content: [ + { type: 'text', text: `Hello, ${name}!` }, + { type: 'text', text: `${JSON.stringify(serializedRequestInfo)}` } + ] + }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-request-info', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { type: 'text', text: 'Hello, Test User!' }, + { type: 'text', text: expect.any(String) } + ] + }, + id: 'call-1' + }); + + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String) + }, + url: expect.stringContaining(baseUrl.pathname), + method: 'POST' + }); + }); + + it('should expose query parameters via the Request object', async () => { + sessionId = await initializeServer(); + + mcpServer.registerTool( + 'test-query-params', + { + description: 'A tool that reads query params', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const req = ctx.http?.req; + const url = new URL(req!.url); + const params = Object.fromEntries(url.searchParams); + return { + content: [{ type: 'text', text: JSON.stringify(params) }] + }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-query-params', + arguments: {} + }, + id: 'call-2' + }; + + // Send to a URL with query parameters + const urlWithParams = new URL(baseUrl.toString()); + urlWithParams.searchParams.set('foo', 'bar'); + urlWithParams.searchParams.set('debug', 'true'); + + const response = await sendPostRequest(urlWithParams, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const dataLine = text.split('\n').find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + const queryParams = JSON.parse(eventData.result.content[0].text); + expect(queryParams).toEqual({ foo: 'bar', debug: 'true' }); + }); + + it('should reject requests without a valid session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(response.status).toBe(400); + const errorData = (await response.json()) as JSONRPCErrorResponse; + expectErrorResponse(errorData, -32_000, /Bad Request/); + expect(errorData.id).toBeNull(); + }); + + it('should reject invalid session ID', async () => { + // First initialize to be in valid state + await initializeServer(); + + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_001, /Session not found/); + }); + + it('should establish standalone SSE stream and receive server-initiated messages', async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); + + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }); + }); + + it('should not close GET SSE stream after sending multiple server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); + + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }; + + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); + + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('First notification'); + expect(done).toBe(false); // Stream should still be open + }); + + it('should reject second SSE stream for the same session', async () => { + sessionId = await initializeServer(); + + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(firstStream.status).toBe(200); + + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32_000, /Only one SSE stream is allowed per session/); + }); + + it('should reject GET requests without Accept: text/event-stream header', async () => { + sessionId = await initializeServer(); + + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Client must accept text\/event-stream/); + }); + + it('should reject POST requests without proper Accept header', async () => { + sessionId = await initializeServer(); + + // Try POST without Accept: text/event-stream + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', // Missing text/event-stream + 'mcp-session-id': sessionId + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Client must accept both application\/json and text\/event-stream/); + }); + + it('should reject unsupported Content-Type', async () => { + sessionId = await initializeServer(); + + // Try POST with text/plain Content-Type + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: 'This is plain text' + }); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Content-Type must be application\/json/); + }); + + it('should handle JSON-RPC batch notification messages with 202 response', async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'someNotification1', params: {} }, + { jsonrpc: '2.0', method: 'someNotification2', params: {} } + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); + }); + + it('should handle batch request messages with SSE stream for responses', async () => { + sessionId = await initializeServer(); + + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it('should properly handle invalid JSON data', async () => { + sessionId = await initializeServer(); + + // Send invalid JSON + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: 'This is not valid JSON' + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_700, /Parse error/); + }); + + it('should include error data in parse error response for unexpected errors', async () => { + sessionId = await initializeServer(); + + // We can't easily trigger the catch-all error handler, but we can verify + // that the JSON parse error includes useful information + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: '{ invalid json }' + }); + + expect(response.status).toBe(400); + const errorData = (await response.json()) as JSONRPCErrorResponse; + expectErrorResponse(errorData, -32_700, /Parse error/); + // The error message should contain details about what went wrong + expect(errorData.error.message).toContain('Invalid JSON'); + }); + + it('should return 400 error for invalid JSON-RPC messages', async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: '2.0', + error: expect.anything() + }); + }); + + it('should reject requests to uninitialized server', async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'uninitialized-test' + }; + + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); + }); + + it('should send response messages to the connection that sent the request', async () => { + sessionId = await initializeServer(); + + const message1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'req-1' + }; + + const message2: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'Connection2' } + }, + id: 'req-2' + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it('should keep stream open after sending server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + // Send several server-initiated notifications + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }); + + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Second notification' } + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it('should properly handle DELETE requests and close session', async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up - don't wait indefinitely for server close + tempServer.close(); + }); + + it('should reject DELETE requests with invalid session ID', async () => { + // Initialize the server first to activate it + sessionId = await initializeServer(); + + // Try to delete with invalid session ID + const response = await fetch(baseUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_001, /Session not found/); + }); + + describe('protocol version header validation', () => { + it('should accept requests with matching protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + }); + + it('should accept requests without protocol version header', async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with unsupported protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version: .+ \(supported versions: .+\)/); + }); + + it('should accept when protocol version differs from negotiated version', async () => { + sessionId = await initializeServer(); + + // Send request with different but supported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2024-11-05' // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + // Request should still succeed + expect(response.status).toBe(200); + }); + + it('should reject unsupported protocol version on GET requests', async () => { + sessionId = await initializeServer(); + + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + } + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version/); + }); + + it('should reject unsupported protocol version on DELETE requests', async () => { + sessionId = await initializeServer(); + + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + } + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Bad Request: Unsupported protocol version/); + }); + }); + }); + + describe('NodeStreamableHTTPServerTransport with AuthInfo', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestAuthServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it('should call a tool with authInfo', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: true } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Active profile from token: test-token!' + } + ] + }, + id: 'call-1' + }); + }); + + it('should calls tool without authInfo when it is optional', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: false } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.slice(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Inactive profile from token: undefined!' + } + ] + }, + id: 'call-1' + }); + }); + }); + + // Test JSON Response Mode + describe('NodeStreamableHTTPServerTransport with JSON Response Mode', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should return JSON response for a single request', async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'json-req-1' + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }), + id: 'json-req-1' + }); + }); + + it('should return JSON response for batch requests', async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } + ]; + + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const results = (await response.json()) as JSONRPCResultResponse[]; + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: RequestId }) => r.id === 'batch-1'); + const callResponse = results.find((r: { id?: RequestId }) => r.id === 'batch-2'); + + expect(listResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-1', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }) + }) + ); + + expect(callResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-2', + result: expect.objectContaining({ + content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) + }) + }) + ); + }); + }); + + // Test pre-parsed body handling + describe('NodeStreamableHTTPServerTransport with pre-parsed body', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody === null) { + await transport.handleRequest(req, res); + } else { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }, + sessionIdGenerator: () => randomUUID() + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should accept pre-parsed request body', async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-1' + }; + + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + // Empty body - we're testing pre-parsed body + body: '' + }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); + }); + + it('should handle pre-parsed batch messages', async () => { + parsedBody = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } + ]; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: '' // Empty as we're using pre-parsed + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); + }); + + it('should prefer pre-parsed body over request body', async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-wins' + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Ignored' } }, + id: 'ignored-id' + }) + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); + }); + + // Test resumability support + describe('NodeStreamableHTTPServerTransport with resumability', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); + + // Simple implementation of EventStore + const eventStore: EventStore = { + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + async replayEventsAfter( + lastEventId: EventId, + { + send + }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + } + ): Promise { + const streamId = lastEventId.split('_')[0]!; + // Extract stream ID from the event ID + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + await send(eventId, message); + } + } + return streamId; + } + }; + + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); + + it('should store and include event IDs in server SSE messages', async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification with event ID' } + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]!; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('_GET_stream')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); + + it('should store and replay MCP server tool notifications', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(sseResponse.status).toBe(200); + + // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); + + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]!; + + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); + + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); + + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25', + 'last-event-id': firstEventId + } + }); + + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); + + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); + }); + + it('should store and replay multiple notifications sent while client is disconnected', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(sseResponse.status).toBe(200); + + const reader = sseResponse.body?.getReader(); + + // Send a notification to get an event ID + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial notification' }); + + // Read the notification from the SSE stream + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const lastEventId = idMatch![1]!; + + // Close the SSE stream to simulate a disconnect + await reader!.cancel(); + + // Send MULTIPLE notifications while the client is disconnected + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 1' }); + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 2' }); + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 3' }); + + // Reconnect with the Last-Event-ID to get all missed messages + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25', + 'last-event-id': lastEventId + } + }); + + expect(reconnectResponse.status).toBe(200); + + // Read replayed notifications with a timeout + const reconnectReader = reconnectResponse.body?.getReader(); + let allText = ''; + + // Read chunks until we have all 3 notifications or timeout + const readWithTimeout = async () => { + const timeout = setTimeout(() => reconnectReader!.cancel(), 2000); + try { + while (!allText.includes('Missed notification 3')) { + const { value, done } = await reconnectReader!.read(); + if (done) break; + allText += new TextDecoder().decode(value); + } + } finally { + clearTimeout(timeout); + } + }; + await readWithTimeout(); + + // Verify we received ALL notifications that were sent while disconnected + expect(allText).toContain('Missed notification 1'); + expect(allText).toContain('Missed notification 2'); + expect(allText).toContain('Missed notification 3'); + }); + }); + + // Test stateless mode + describe('NodeStreamableHTTPServerTransport in stateless mode', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should operate without session ID validation', async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get('mcp-session-id')).toBeNull(); + + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(200); + }); + + it('should handle POST requests with various session IDs in stateless mode', async () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'random-id-1' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'different-id-2' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) + }); + expect(response2.status).toBe(200); + }); + + it('should reject second SSE stream even in stateless mode', async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(stream1.status).toBe(200); + + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed + }); + }); + + // Test SSE priming events for POST streams + describe('NodeStreamableHTTPServerTransport POST SSE priming events', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + + // Simple eventStore for priming event tests + const createEventStore = (): EventStore => { + const storedEvents = new Map(); + return { + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async getStreamIdForEventId(eventId: string): Promise { + const event = storedEvents.get(eventId); + return event?.streamId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } + ): Promise { + const event = storedEvents.get(lastEventId); + const streamId = event?.streamId || lastEventId.split('::')[0]!; + const eventsToReplay: Array<[string, { message: JSONRPCMessage }]> = []; + for (const [eventId, data] of storedEvents.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; + }; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + it('should send priming event with retry field on POST SSE stream', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Test' } } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + expect(postResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Read the priming event + const reader = postResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify priming event has id and retry field + expect(text).toContain('id: '); + expect(text).toContain('retry: 5000'); + expect(text).toContain('data: '); + }); + + it('should NOT send priming event for old protocol versions (backwards compatibility)', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Initialize with OLD protocol version to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initializeOldVersion); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Send a tool call request with the same OLD protocol version + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Test' } } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-06-18' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + expect(postResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Read the first chunk - should be the actual response, not a priming event + const reader = postResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should NOT contain a priming event (empty data line before the response) + // The first message should be the actual tool result + expect(text).toContain('event: message'); + expect(text).toContain('"result"'); + // Should NOT have a separate priming event line with empty data + expect(text).not.toMatch(/^id:.*\ndata:\s*\n\n/); + }); + + it('should send priming event without retry field when retryInterval is not configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore() + // No retryInterval + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Test' } } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read the priming event + const reader = postResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Priming event should have id field but NOT retry field + expect(text).toContain('id: '); + expect(text).toContain('data: '); + expect(text).not.toContain('retry:'); + }); + + it('should close POST SSE stream when ctx.http?.closeSSE is called', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track when stream close is called and tool completes + let streamCloseCalled = false; + let toolResolve: () => void; + const toolCompletePromise = new Promise(resolve => { + toolResolve = resolve; + }); + + // Register a tool that closes its own SSE stream via ctx callback + mcpServer.registerTool('close-stream-tool', { description: 'Closes its own stream' }, async ctx => { + // Close the SSE stream for this request + ctx.http?.closeSSE?.(); + streamCloseCalled = true; + + // Wait before returning so we can observe the stream closure + await toolCompletePromise; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'close-stream-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + const reader = postResponse.body?.getReader(); + + // Read the priming event + await reader!.read(); + + // Wait a moment for the tool to call closeSSEStream + await new Promise(resolve => setTimeout(resolve, 100)); + expect(streamCloseCalled).toBe(true); + + // Stream should now be closed + const { done } = await reader!.read(); + expect(done).toBe(true); + + // Clean up - resolve the tool promise + toolResolve!(); + }); + + it('should provide closeSSEStream callback in ctx when eventStore is configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeSSEStream callback was provided + let receivedCloseSSEStream: (() => void) | undefined; + + // Register a tool that captures the ctx.http?.closeSSE callback + mcpServer.registerTool('test-callback-tool', { description: 'Test tool' }, async ctx => { + receivedCloseSSEStream = ctx.http?.closeSSE; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 200, + method: 'tools/call', + params: { name: 'test-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeSSEStream callback was provided + expect(receivedCloseSSEStream).toBeDefined(); + expect(typeof receivedCloseSSEStream).toBe('function'); + }); + + it('should NOT provide closeSSEStream callback for old protocol versions (backwards compatibility)', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeSSEStream callback was provided + let receivedCloseSSEStream: (() => void) | undefined; + let receivedCloseStandaloneSSEStream: (() => void) | undefined; + + // Register a tool that captures the ctx.http?.closeSSE callback + mcpServer.registerTool('test-old-version-tool', { description: 'Test tool' }, async ctx => { + receivedCloseSSEStream = ctx.http?.closeSSE; + receivedCloseStandaloneSSEStream = ctx.http?.closeStandaloneSSE; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize with OLD protocol version to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initializeOldVersion); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool with the same OLD protocol version + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 200, + method: 'tools/call', + params: { name: 'test-old-version-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-06-18' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeSSEStream callbacks were NOT provided for old protocol version + // even though eventStore is configured + expect(receivedCloseSSEStream).toBeUndefined(); + expect(receivedCloseStandaloneSSEStream).toBeUndefined(); + }); + + it('should NOT provide closeSSEStream callback when eventStore is NOT configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + // No eventStore + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeSSEStream callback was provided + let receivedCloseSSEStream: (() => void) | undefined; + + // Register a tool that captures the ctx.http?.closeSSE callback + mcpServer.registerTool('test-no-callback-tool', { description: 'Test tool' }, async ctx => { + receivedCloseSSEStream = ctx.http?.closeSSE; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 201, + method: 'tools/call', + params: { name: 'test-no-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeSSEStream callback was NOT provided + expect(receivedCloseSSEStream).toBeUndefined(); + }); + + it('should provide closeStandaloneSSEStream callback in ctx when eventStore is configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Track whether closeStandaloneSSEStream callback was provided + let receivedCloseStandaloneSSEStream: (() => void) | undefined; + + // Register a tool that captures the ctx.http?.closeStandaloneSSE callback + mcpServer.registerTool('test-standalone-callback-tool', { description: 'Test tool' }, async ctx => { + receivedCloseStandaloneSSEStream = ctx.http?.closeStandaloneSSE; + return { content: [{ type: 'text', text: 'Done' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Call the tool + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 203, + method: 'tools/call', + params: { name: 'test-standalone-callback-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + + expect(postResponse.status).toBe(200); + + // Read all events to completion + const reader = postResponse.body?.getReader(); + while (true) { + const { done } = await reader!.read(); + if (done) break; + } + + // Verify closeStandaloneSSEStream callback was provided + expect(receivedCloseStandaloneSSEStream).toBeDefined(); + expect(typeof receivedCloseStandaloneSSEStream).toBe('function'); + }); + + it('should close standalone GET SSE stream when ctx.http?.closeStandaloneSSE is called', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Register a tool that closes the standalone SSE stream via ctx callback + mcpServer.registerTool('close-standalone-stream-tool', { description: 'Closes standalone stream' }, async ctx => { + ctx.http?.closeStandaloneSSE?.(); + return { content: [{ type: 'text', text: 'Stream closed' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Open a standalone GET SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(sseResponse.status).toBe(200); + + const getReader = sseResponse.body?.getReader(); + + // Send a notification to confirm GET stream is established + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Stream established' }); + + // Read the notification to confirm stream is working + const { value } = await getReader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('id: '); + expect(text).toContain('Stream established'); + + // Call the tool that closes the standalone SSE stream + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 300, + method: 'tools/call', + params: { name: 'close-standalone-stream-tool', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + expect(postResponse.status).toBe(200); + + // Read the POST response to completion + const postReader = postResponse.body?.getReader(); + while (true) { + const { done } = await postReader!.read(); + if (done) break; + } + + // GET stream should now be closed - use a race with timeout to avoid hanging + const readPromise = getReader!.read(); + const timeoutPromise = new Promise<{ done: boolean; value: undefined }>((_, reject) => + setTimeout(() => reject(new Error('Stream did not close in time')), 1000) + ); + + const { done } = await Promise.race([readPromise, timeoutPromise]); + expect(done).toBe(true); + }); + + it('should allow client to reconnect after standalone SSE stream is closed via ctx.http?.closeStandaloneSSE', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Register a tool that closes the standalone SSE stream + mcpServer.registerTool('close-standalone-for-reconnect', { description: 'Closes standalone stream' }, async ctx => { + ctx.http?.closeStandaloneSSE?.(); + return { content: [{ type: 'text', text: 'Stream closed' }] }; + }); + + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + + // Open a standalone GET SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + } + }); + expect(sseResponse.status).toBe(200); + + const getReader = sseResponse.body?.getReader(); + + // Send a notification to get an event ID + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial message' }); + + // Read the notification to get the event ID + const { value } = await getReader!.read(); + const text = new TextDecoder().decode(value); + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const lastEventId = idMatch![1]!; + + // Call the tool to close the standalone SSE stream + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 301, + method: 'tools/call', + params: { name: 'close-standalone-for-reconnect', arguments: {} } + }; + + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(toolCallRequest) + }); + expect(postResponse.status).toBe(200); + + // Read the POST response to completion + const postReader = postResponse.body?.getReader(); + while (true) { + const { done } = await postReader!.read(); + if (done) break; + } + + // Wait for GET stream to close - use a race with timeout + const readPromise = getReader!.read(); + const timeoutPromise = new Promise<{ done: boolean; value: undefined }>((_, reject) => + setTimeout(() => reject(new Error('Stream did not close in time')), 1000) + ); + const { done } = await Promise.race([readPromise, timeoutPromise]); + expect(done).toBe(true); + + // Wait a bit to ensure the next notification gets a different timestamp. + // The eventStore uses Date.now() in event IDs, and if two events have the same + // timestamp, the UUID suffix ordering is random and may not preserve creation order. + await new Promise(resolve => setTimeout(resolve, 5)); + + // Send a notification while client is disconnected + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed while disconnected' }); + + // Client reconnects with Last-Event-ID + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25', + 'last-event-id': lastEventId + } + }); + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + let allText = ''; + const readWithTimeout = async () => { + const timeout = setTimeout(() => reconnectReader!.cancel(), 5000); + try { + while (!allText.includes('Missed while disconnected')) { + const { value, done } = await reconnectReader!.read(); + if (done) break; + allText += new TextDecoder().decode(value); + } + } finally { + clearTimeout(timeout); + } + }; + await readWithTimeout(); + + // Verify we received the notification that was sent while disconnected + expect(allText).toContain('Missed while disconnected'); + }, 15_000); + }); + + // Test onsessionclosed callback + describe('NodeStreamableHTTPServerTransport onsessionclosed callback', () => { + it('should call onsessionclosed callback when session is closed via DELETE', async () => { + const mockCallback = vi.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Clean up + tempServer.close(); + }); + + it('should not call onsessionclosed callback when not provided', async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); + }); + + it('should not call onsessionclosed callback for invalid session DELETE', async () => { + const mockCallback = vi.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); + + // Clean up + tempServer.close(); + }); + + it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { + const mockCallback = vi.fn(); + + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const server1 = result1.server; + const url1 = result1.baseUrl; + + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const server2 = result2.server; + const url2 = result2.baseUrl; + + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get('mcp-session-id'); + + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get('mcp-session-id'); + + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId1 || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId2 || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); + + // Clean up + server1.close(); + server2.close(); + }); + }); + + // Test async callbacks for onsessioninitialized and onsessionclosed + describe('NodeStreamableHTTPServerTransport async callbacks', () => { + it('should support async onsessioninitialized callback', async () => { + const initializationOrder: string[] = []; + + // Create server with async onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + initializationOrder.push('async-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + initializationOrder.push('async-end', sessionId); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { + const capturedSessionId: string[] = []; + + // Create server with sync onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + capturedSessionId.push(sessionId); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + expect(capturedSessionId).toEqual([tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should support async onsessionclosed callback', async () => { + const closureOrder: string[] = []; + + // Create server with async onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (sessionId: string) => { + closureOrder.push('async-close-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + closureOrder.push('async-close-end', sessionId); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should propagate errors from async onsessioninitialized callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create server with async onsessioninitialized callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (_sessionId: string) => { + throw new Error('Async initialization error'); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize should fail when callback throws + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(400); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it('should propagate errors from async onsessionclosed callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create server with async onsessionclosed callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (_sessionId: string) => { + throw new Error('Async closure error'); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // DELETE should fail when callback throws + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(500); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it('should handle both async callbacks together', async () => { + const events: string[] = []; + + // Create server with both async callbacks + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`initialized:${sessionId}`); + }, + onsessionclosed: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`closed:${sessionId}`); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger first callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`initialized:${tempSessionId}`); + + // DELETE to trigger second callback + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-11-25' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`closed:${tempSessionId}`); + expect(events).toHaveLength(2); + + // Clean up + tempServer.close(); + }); + }); + + // Test DNS rebinding protection + describe('NodeStreamableHTTPServerTransport DNS rebinding protection', () => { + let server: Server; + let transport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with disallowed host headers', async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = (await response.json()) as JSONRPCErrorResponse; + expect(body.error.message).toContain('Invalid Host header:'); + }); + + it('should reject GET requests with disallowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream' + } + }); + + expect(response.status).toBe(403); + }); + }); + + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3000' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with disallowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = (await response.json()) as JSONRPCErrorResponse; + expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); + }); + + it('should accept requests without origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Should pass even with no Origin headers because requests that do not come from browsers may not have Origin and DNS rebinding attacks can only be performed via browsers + expect(response.status).toBe(200); + }); + }); + + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: false + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Host: 'evil.com', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); + }); + }); + + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response1.status).toBe(403); + const body1 = (await response1.json()) as JSONRPCErrorResponse; + expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3001' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response2.status).toBe(200); + }); + }); + }); +}); + +describe('NodeStreamableHTTPServerTransport global Response preservation', () => { + it('should not override the global Response object', () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response (similar to Next.js's NextResponse) + class CustomResponse extends Response { + customProperty = 'test'; + } + + // Verify instanceof works before creating transport + const customResponseBefore = new CustomResponse('test body'); + expect(customResponseBefore instanceof Response).toBe(true); + expect(customResponseBefore instanceof OriginalResponse).toBe(true); + + // Create the transport - this should NOT override globalThis.Response + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Verify the global Response is still the original + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works after creating transport + const customResponseAfter = new CustomResponse('test body'); + expect(customResponseAfter instanceof Response).toBe(true); + expect(customResponseAfter instanceof OriginalResponse).toBe(true); + + // Verify that instances created before transport initialization still work + expect(customResponseBefore instanceof Response).toBe(true); + + // Clean up + transport.close(); + }); + + it('should not override the global Response object when calling handleRequest', async () => { + // Store reference to the original global Response constructor + const OriginalResponse = globalThis.Response; + + // Create a custom class that extends Response + class CustomResponse extends Response { + customProperty = 'test'; + } + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Create a mock server to test handleRequest + const port = await getFreePort(); + const httpServer = createServer(async (req, res) => { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + }); + + await new Promise(resolve => { + httpServer.listen(port, () => resolve()); + }); + + try { + // Make a request to trigger handleRequest + await fetch(`http://localhost:${port}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Verify the global Response is still the original after handleRequest + expect(globalThis.Response).toBe(OriginalResponse); + + // Verify instanceof still works + const customResponse = new CustomResponse('test body'); + expect(customResponse instanceof Response).toBe(true); + expect(customResponse instanceof OriginalResponse).toBe(true); + } finally { + await transport.close(); + httpServer.close(); + } + }); +}); + +/** + * Helper to create test server with DNS rebinding protection options + */ +async function createTestServerWithDnsProtection(config: { + sessionIdGenerator: (() => string) | undefined; + allowedHosts?: string[]; + allowedOrigins?: string[]; + enableDnsRebindingProtection?: boolean; +}): Promise<{ + server: Server; + transport: NodeStreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + const port = await getFreePort(); + + if (config.allowedHosts) { + config.allowedHosts = config.allowedHosts.map(host => { + if (host.includes(':')) { + return host; + } + return `localhost:${port}`; + }); + } + + const transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + allowedHosts: config.allowedHosts, + allowedOrigins: config.allowedOrigins, + enableDnsRebindingProtection: config.enableDnsRebindingProtection + }); + + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', async () => { + const parsedBody = JSON.parse(body); + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); + }); + } else { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + } + }); + + await new Promise(resolve => { + httpServer.listen(port, () => resolve()); + }); + + const serverUrl = new URL(`http://localhost:${port}/`); + + return { + server: httpServer, + transport, + mcpServer, + baseUrl: serverUrl + }; +} diff --git a/packages/middleware/node/tsconfig.json b/packages/middleware/node/tsconfig.json new file mode 100644 index 0000000..0985895 --- /dev/null +++ b/packages/middleware/node/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] + } + } +} diff --git a/packages/middleware/node/tsdown.config.ts b/packages/middleware/node/tsdown.config.ts new file mode 100644 index 0000000..7d90f65 --- /dev/null +++ b/packages/middleware/node/tsdown.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + // 1. Entry Points + // Directly matches package.json include/exclude globs + entry: ['src/index.ts'], + + // 2. Output Configuration + format: ['esm'], + outDir: 'dist', + clean: true, // Recommended: Cleans 'dist' before building + sourcemap: true, + + // 3. Platform & Target + target: 'esnext', + platform: 'node', + shims: true, // Polyfills common Node.js shims (__dirname, etc.) + + // 4. Type Definitions + // Bundles d.ts files into a single output + dts: { + resolver: 'tsc', + // override just for DTS generation: + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/core': ['../core/src/index.ts'] + } + } + } +}); diff --git a/packages/middleware/node/typedoc.json b/packages/middleware/node/typedoc.json new file mode 100644 index 0000000..dd70079 --- /dev/null +++ b/packages/middleware/node/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/middleware/node/vitest.config.js b/packages/middleware/node/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/middleware/node/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md new file mode 100644 index 0000000..3f27e84 --- /dev/null +++ b/packages/server/CHANGELOG.md @@ -0,0 +1,116 @@ +# @modelcontextprotocol/server + +## 2.0.0-alpha.2 + +### Patch Changes + +- [#1840](https://github.com/modelcontextprotocol/typescript-sdk/pull/1840) [`424cbae`](https://github.com/modelcontextprotocol/typescript-sdk/commit/424cbaeee13b7fe18d38048295135395b9ad81bb) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tsdown exports resolution + fix + +## 2.0.0-alpha.1 + +### Major Changes + +- [#1389](https://github.com/modelcontextprotocol/typescript-sdk/pull/1389) [`108f2f3`](https://github.com/modelcontextprotocol/typescript-sdk/commit/108f2f3ab6a1267587c7c4f900b6eca3cc2dae51) Thanks [@DePasqualeOrg](https://github.com/DePasqualeOrg)! - Fix error handling for + unknown tools and resources per MCP spec. + + **Tools:** Unknown or disabled tool calls now return JSON-RPC protocol errors with code `-32602` (InvalidParams) instead of `CallToolResult` with `isError: true`. Callers who checked `result.isError` for unknown tools should catch rejected promises instead. + + **Resources:** Unknown resource reads now return error code `-32002` (ResourceNotFound) instead of `-32602` (InvalidParams). + + Added `ProtocolErrorCode.ResourceNotFound`. + +### Minor Changes + +- [#1673](https://github.com/modelcontextprotocol/typescript-sdk/pull/1673) [`462c3fc`](https://github.com/modelcontextprotocol/typescript-sdk/commit/462c3fc47dffac908d2ba27784d47ff010fa065e) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - refactor: extract task + orchestration from Protocol into TaskManager + + **Breaking changes:** + - `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions` + +- [#1689](https://github.com/modelcontextprotocol/typescript-sdk/pull/1689) [`0784be1`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0784be1a67fb3cc2aba0182d88151264f4ea73c8) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Support Standard Schema + for tool and prompt schemas + + Tool and prompt registration now accepts any schema library that implements the [Standard Schema spec](https://standardschema.dev/): Zod v4, Valibot, ArkType, and others. `RegisteredTool.inputSchema`, `RegisteredTool.outputSchema`, and `RegisteredPrompt.argsSchema` now use + `StandardSchemaWithJSON` (requires both `~standard.validate` and `~standard.jsonSchema`) instead of the Zod-specific `AnySchema` type. + + **Zod v4 schemas continue to work unchanged** — Zod v4 implements the required interfaces natively. + + ```typescript + import { type } from 'arktype'; + + server.registerTool( + 'greet', + { + inputSchema: type({ name: 'string' }) + }, + async ({ name }) => ({ content: [{ type: 'text', text: `Hello, ${name}!` }] }) + ); + ``` + + For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter: + + ```typescript + import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core'; + + server.registerTool( + 'greet', + { + inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }, new AjvJsonSchemaValidator()) + }, + handler + ); + ``` + + **Breaking changes:** + - `experimental.tasks.getTaskResult()` no longer accepts a `resultSchema` parameter. Returns `GetTaskPayloadResult` (a loose `Result`); cast to the expected type at the call site. + - Removed unused exports from `@modelcontextprotocol/core`: `SchemaInput`, `schemaToJson`, `parseSchemaAsync`, `getSchemaShape`, `getSchemaDescription`, `isOptionalSchema`, `unwrapOptionalSchema`. Use the new `standardSchemaToJsonSchema` and `validateStandardSchema` instead. + - `completable()` remains Zod-specific (it relies on Zod's `.shape` introspection). + +### Patch Changes + +- [#1758](https://github.com/modelcontextprotocol/typescript-sdk/pull/1758) [`e86b183`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e86b1835ccf213c3799ac19f4111d01816912333) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - tasks - disallow requesting + a null TTL + +- [#1363](https://github.com/modelcontextprotocol/typescript-sdk/pull/1363) [`0a75810`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0a75810b26e24bae6b9cfb41e12ac770aeaa1da4) Thanks [@DevJanderson](https://github.com/DevJanderson)! - Fix ReDoS vulnerability in + UriTemplate regex patterns (CVE-2026-0621) + +- [#1372](https://github.com/modelcontextprotocol/typescript-sdk/pull/1372) [`3466a9e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3466a9e0e5d392824156d9b290863ae08192d87e) Thanks [@mattzcarey](https://github.com/mattzcarey)! - missing change for fix(client): + replace body.cancel() with text() to prevent hanging + +- [#1824](https://github.com/modelcontextprotocol/typescript-sdk/pull/1824) [`fcde488`](https://github.com/modelcontextprotocol/typescript-sdk/commit/fcde4882276cb0a7d199e47f00120fe13f7f5d47) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Drop `zod` from + `peerDependencies` (kept as direct dependency) + + Since Standard Schema support landed, `zod` is purely an internal runtime dependency used for protocol message parsing. User-facing schemas (`registerTool`, `registerPrompt`) accept any Standard Schema library. `zod` remains in `dependencies` and auto-installs; users no + longer need to install it alongside the SDK. + +- [#1761](https://github.com/modelcontextprotocol/typescript-sdk/pull/1761) [`01954e6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/01954e621afe525cc3c1bbe8d781e44734cf81c2) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Convert remaining + capability-assertion throws to `SdkError(SdkErrorCode.CapabilityNotSupported, ...)`. Follow-up to #1454 which missed `Client.assertCapability()`, the task capability helpers in `experimental/tasks/helpers.ts`, and the sampling/elicitation capability checks in + `experimental/tasks/server.ts`. + +- [#1433](https://github.com/modelcontextprotocol/typescript-sdk/pull/1433) [`78bae74`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78bae7426d4ca38216c0571b5aa7806f58ab81e4) Thanks [@codewithkenzo](https://github.com/codewithkenzo)! - Fix transport errors being + silently swallowed by adding missing `onerror` callback invocations before all `createJsonErrorResponse` calls in `WebStandardStreamableHTTPServerTransport`. This ensures errors like parse failures, invalid headers, and session validation errors are properly reported via the + `onerror` callback. + +- [#1660](https://github.com/modelcontextprotocol/typescript-sdk/pull/1660) [`689148d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/689148dc7235f53244869ed64b2ecc9ec4ef70f1) Thanks [@rechedev9](https://github.com/rechedev9)! - fix(server): propagate negotiated + protocol version to transport in \_oninitialize + +- [#1568](https://github.com/modelcontextprotocol/typescript-sdk/pull/1568) [`f1ade75`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f1ade75b67a2b46b06316fa4e5caa1d277537cc7) Thanks [@stakeswky](https://github.com/stakeswky)! - Handle stdout errors (e.g. EPIPE) + in `StdioServerTransport` gracefully instead of crashing. When the client disconnects abruptly, the transport now catches the stdout error, surfaces it via `onerror`, and closes. + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - remove deprecated .tool, + .prompt, .resource method signatures + +- [#1388](https://github.com/modelcontextprotocol/typescript-sdk/pull/1388) [`f66a55b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f66a55b5f4eb7ce0f8b3885633bf9a7b1080e0b5) Thanks [@mattzcarey](https://github.com/mattzcarey)! - reverting application/json in + notifications + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - remove npm references, use pnpm + +- [#1534](https://github.com/modelcontextprotocol/typescript-sdk/pull/1534) [`69a0626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/69a062693f61e024d7a366db0c3e3ba74ff59d8e) Thanks [@josefaidt](https://github.com/josefaidt)! - clean up package manager usage, all + pnpm + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - deprecated .tool, .prompt, + .resource method removal + +- [#1279](https://github.com/modelcontextprotocol/typescript-sdk/pull/1279) [`71ae3ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/71ae3acee0203a1023817e3bffcd172d0966d2ac) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Initial 2.0.0-alpha.0 + client and server package diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..6f9ccf8 --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,27 @@ +# `@modelcontextprotocol/server` + +The MCP (Model Context Protocol) TypeScript server SDK. Build MCP servers that expose tools, resources, and prompts. + + +> [!WARNING] +> **This is an alpha release.** Expect breaking changes until v2 stabilizes. We're publishing early to gather feedback — please try it and open issues — but we can't guarantee API stability yet. We'll aim to minimize disruption between alphas. + + +> [!NOTE] +> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration.md)** if you're coming from v1. + +## Install + +```bash +npm install @modelcontextprotocol/server@alpha +``` + +Optional framework adapters: [`@modelcontextprotocol/express`](https://www.npmjs.com/package/@modelcontextprotocol/express), [`@modelcontextprotocol/hono`](https://www.npmjs.com/package/@modelcontextprotocol/hono), +[`@modelcontextprotocol/node`](https://www.npmjs.com/package/@modelcontextprotocol/node). + +## Documentation + +- **[Repository README](https://github.com/modelcontextprotocol/typescript-sdk#readme)** — overview, package layout, examples +- **[Server guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/server.md)** +- **[API reference](https://ts.sdk.modelcontextprotocol.io/v2/)** +- **[MCP specification](https://modelcontextprotocol.io)** diff --git a/packages/server/eslint.config.mjs b/packages/server/eslint.config.mjs new file mode 100644 index 0000000..4f034f2 --- /dev/null +++ b/packages/server/eslint.config.mjs @@ -0,0 +1,12 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default [ + ...baseConfig, + { + settings: { + 'import/internal-regex': '^@modelcontextprotocol/core' + } + } +]; diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..20195e7 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,108 @@ +{ + "name": "@modelcontextprotocol/server", + "version": "2.0.0-alpha.2", + "description": "Model Context Protocol implementation for TypeScript - Server package", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "modelcontextprotocol", + "mcp", + "server" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + }, + "./stdio": { + "types": "./dist/stdio.d.mts", + "import": "./dist/stdio.mjs" + }, + "./validators/cf-worker": { + "types": "./dist/validators/cfWorker.d.mts", + "import": "./dist/validators/cfWorker.mjs" + }, + "./_shims": { + "workerd": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "browser": { + "types": "./dist/shimsWorkerd.d.mts", + "import": "./dist/shimsWorkerd.mjs" + }, + "node": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + }, + "default": { + "types": "./dist/shimsNode.d.mts", + "import": "./dist/shimsNode.mjs" + } + } + }, + "types": "./dist/index.d.mts", + "typesVersions": { + "*": { + "validators/cf-worker": [ + "dist/validators/cfWorker.d.mts" + ], + "zod-schemas": [ + "dist/zodSchemas.d.mts" + ], + "stdio": [ + "dist/stdio.d.mts" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", + "build": "tsdown", + "build:watch": "tsdown --watch", + "prepack": "pnpm run build", + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "pnpm run typecheck && pnpm run lint", + "test": "vitest run", + "test:watch": "vitest", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "dependencies": { + "zod": "catalog:runtimeShared" + }, + "devDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "@eslint/js": "catalog:devTools", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@types/eventsource": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", + "eslint": "catalog:devTools", + "eslint-config-prettier": "catalog:devTools", + "eslint-plugin-n": "catalog:devTools", + "prettier": "catalog:devTools", + "supertest": "catalog:devTools", + "tsdown": "catalog:devTools", + "tsx": "catalog:devTools", + "typescript": "catalog:devTools", + "typescript-eslint": "catalog:devTools", + "vitest": "catalog:devTools" + } +} diff --git a/packages/server/src/experimental/index.ts b/packages/server/src/experimental/index.ts new file mode 100644 index 0000000..55dd44e --- /dev/null +++ b/packages/server/src/experimental/index.ts @@ -0,0 +1,13 @@ +/** + * Experimental MCP SDK features. + * WARNING: These APIs are experimental and may change without notice. + * + * Import experimental features from this module: + * ```typescript + * import { TaskStore, InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental'; + * ``` + * + * @experimental + */ + +export * from './tasks/index.js'; diff --git a/packages/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts new file mode 100644 index 0000000..6917fe6 --- /dev/null +++ b/packages/server/src/experimental/tasks/index.ts @@ -0,0 +1,10 @@ +/** + * Experimental task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +export * from './interfaces.js'; +export * from './mcpServer.js'; +export * from './server.js'; diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts new file mode 100644 index 0000000..2aef91a --- /dev/null +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -0,0 +1,66 @@ +/** + * Experimental task interfaces for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + */ + +import type { + CallToolResult, + CreateTaskResult, + CreateTaskServerContext, + GetTaskResult, + Result, + StandardSchemaWithJSON, + TaskServerContext +} from '@modelcontextprotocol/core'; + +import type { BaseToolCallback } from '../../server/mcp.js'; + +// ============================================================================ +// Task Handler Types (for registerToolTask) +// ============================================================================ + +/** + * Handler for creating a task. + * @experimental + */ +export type CreateTaskRequestHandler< + SendResultT extends Result, + Args extends StandardSchemaWithJSON | undefined = undefined +> = BaseToolCallback; + +/** + * Handler for task operations (`get`, `getResult`). + * @experimental + */ +export type TaskRequestHandler = BaseToolCallback< + SendResultT, + TaskServerContext, + Args +>; + +/** + * Interface for task-based tool handlers. + * + * Task-based tools split a long-running operation into three phases: + * `createTask`, `getTask`, and `getTaskResult`. + * + * @see {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | registerToolTask} for registration. + * @experimental + */ +export interface ToolTaskHandler { + /** + * Called on the initial `tools/call` request. + * + * Creates a task via `ctx.task.store.createTask(...)`, starts any + * background work, and returns the task object. + */ + createTask: CreateTaskRequestHandler; + /** + * Handler for `tasks/get` requests. + */ + getTask: TaskRequestHandler; + /** + * Handler for `tasks/result` requests. + */ + getTaskResult: TaskRequestHandler; +} diff --git a/packages/server/src/experimental/tasks/mcpServer.ts b/packages/server/src/experimental/tasks/mcpServer.ts new file mode 100644 index 0000000..b7c28c4 --- /dev/null +++ b/packages/server/src/experimental/tasks/mcpServer.ts @@ -0,0 +1,139 @@ +/** + * Experimental {@linkcode McpServer} task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { StandardSchemaWithJSON, TaskToolExecution, ToolAnnotations, ToolExecution } from '@modelcontextprotocol/core'; + +import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; +import type { ToolTaskHandler } from './interfaces.js'; + +/** + * Internal interface for accessing {@linkcode McpServer}'s private _createRegisteredTool method. + * @internal + */ +interface McpServerInternal { + _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: StandardSchemaWithJSON | undefined, + outputSchema: StandardSchemaWithJSON | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool; +} + +/** + * Experimental task features for {@linkcode McpServer}. + * + * Access via `server.experimental.tasks`: + * ```typescript + * server.experimental.tasks.registerToolTask('long-running', config, handler); + * ``` + * + * @experimental + */ +export class ExperimentalMcpServerTasks { + constructor(private readonly _mcpServer: McpServer) {} + + /** + * Registers a task-based tool with a config object and handler. + * + * Task-based tools support long-running operations that can be polled for status + * and results. The handler must implement {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, and {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} + * methods. + * + * @example + * ```typescript + * server.experimental.tasks.registerToolTask('long-computation', { + * description: 'Performs a long computation', + * inputSchema: z.object({ input: z.string() }), + * execution: { taskSupport: 'required' } + * }, { + * createTask: async (args, ctx) => { + * const task = await ctx.task.store.createTask({ ttl: 300000 }); + * startBackgroundWork(task.taskId, args); + * return { task }; + * }, + * getTask: async (args, ctx) => { + * return ctx.task.store.getTask(ctx.task.id); + * }, + * getTaskResult: async (args, ctx) => { + * return ctx.task.store.getTaskResult(ctx.task.id); + * } + * }); + * ``` + * + * @param name - The tool name + * @param config - Tool configuration (description, schemas, etc.) + * @param handler - Task handler with {@linkcode ToolTaskHandler.createTask | createTask}, {@linkcode ToolTaskHandler.getTask | getTask}, {@linkcode ToolTaskHandler.getTaskResult | getTaskResult} methods + * @returns {@linkcode server/mcp.RegisteredTool | RegisteredTool} for managing the tool's lifecycle + * + * @experimental + */ + registerToolTask( + name: string, + config: { + title?: string; + description?: string; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + execution?: TaskToolExecution; + _meta?: Record; + }, + handler: ToolTaskHandler + ): RegisteredTool; + + registerToolTask( + name: string, + config: { + title?: string; + description?: string; + inputSchema: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + execution?: TaskToolExecution; + _meta?: Record; + }, + handler: ToolTaskHandler + ): RegisteredTool; + + registerToolTask( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + execution?: TaskToolExecution; + _meta?: Record; + }, + handler: ToolTaskHandler + ): RegisteredTool { + // Validate that taskSupport is not 'forbidden' for task-based tools + const execution: ToolExecution = { taskSupport: 'required', ...config.execution }; + if (execution.taskSupport === 'forbidden') { + throw new Error(`Cannot register task-based tool '${name}' with taskSupport 'forbidden'. Use registerTool() instead.`); + } + + // Access McpServer's internal _createRegisteredTool method + const mcpServerInternal = this._mcpServer as unknown as McpServerInternal; + return mcpServerInternal._createRegisteredTool( + name, + config.title, + config.description, + config.inputSchema, + config.outputSchema, + config.annotations, + execution, + config._meta, + handler as AnyToolHandler + ); + } +} diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts new file mode 100644 index 0000000..2e7b205 --- /dev/null +++ b/packages/server/src/experimental/tasks/server.ts @@ -0,0 +1,298 @@ +/** + * Experimental server task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { + AnyObjectSchema, + CancelTaskResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + GetTaskPayloadResult, + GetTaskResult, + ListTasksResult, + Request, + RequestMethod, + RequestOptions, + ResponseMessage, + ResultTypeMap +} from '@modelcontextprotocol/core'; +import { getResultSchema, GetTaskPayloadResultSchema, SdkError, SdkErrorCode } from '@modelcontextprotocol/core'; + +import type { Server } from '../../server/server.js'; + +/** + * Experimental task features for low-level MCP servers. + * + * Access via `server.experimental.tasks`: + * ```typescript + * const stream = server.experimental.tasks.requestStream(request, options); + * ``` + * + * For high-level server usage with task-based tools, use {@linkcode index.McpServer | McpServer}.experimental.tasks instead. + * + * @experimental + */ +export class ExperimentalServerTasks { + constructor(private readonly _server: Server) {} + + private get _module() { + return this._server.taskManager; + } + + /** + * Sends a request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a `'result'` or `'error'` message. + * + * This method provides streaming access to request processing, allowing you to + * observe intermediate task status updates for task-augmented requests. + * + * @param request - The request to send (method name determines the result schema) + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields {@linkcode ResponseMessage} objects + * + * @experimental + */ + requestStream( + request: { method: M; params?: Record }, + options?: RequestOptions + ): AsyncGenerator, void, void> { + const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema; + return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator< + ResponseMessage, + void, + void + >; + } + + /** + * Sends a sampling request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests, yields 'taskCreated' and 'taskStatus' messages + * before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.createMessageStream({ + * messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + * maxTokens: 100 + * }, { + * onprogress: (progress) => { + * // Handle streaming tokens via progress notifications + * console.log('Progress:', progress.message); + * } + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('Final result:', message.result); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The sampling request parameters + * @param options - Optional request options (timeout, signal, task creation params, onprogress, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + createMessageStream( + params: CreateMessageRequestParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + + // Capability check - only required when tools/toolChoice are provided + if ((params.tools || params.toolChoice) && !clientCapabilities?.sampling?.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); + } + + // Message structure validation - always validate tool_use/tool_result pairs. + // These may appear even without tools/toolChoice in the current request when + // a previous sampling request returned tool_use and this is a follow-up with results. + if (params.messages.length > 0) { + const lastMessage = params.messages.at(-1)!; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + + const previousMessage = params.messages.length > 1 ? params.messages.at(-2) : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new Error('The last message must contain only tool_result content if any is present'); + } + if (!hasPreviousToolUse) { + throw new Error('tool_result blocks are not matching any tool_use from the previous message'); + } + } + if (hasPreviousToolUse) { + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => c.id)); + const toolResultIds = new Set(lastContent.filter(c => c.type === 'tool_result').map(c => c.toolUseId)); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new Error('ids of tool_result blocks and tool_use blocks from previous message do not match'); + } + } + } + + return this.requestStream( + { + method: 'sampling/createMessage', + params + }, + options + ) as AsyncGenerator, void, void>; + } + + /** + * Sends an elicitation request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * For task-augmented requests (especially URL-based elicitation), yields 'taskCreated' + * and 'taskStatus' messages before the final result. + * + * @example + * ```typescript + * const stream = server.experimental.tasks.elicitInputStream({ + * mode: 'url', + * message: 'Please authenticate', + * elicitationId: 'auth-123', + * url: 'https://example.com/auth' + * }, { + * task: { ttl: 300000 } // Task-augmented for long-running auth flow + * }); + * + * for await (const message of stream) { + * switch (message.type) { + * case 'taskCreated': + * console.log('Task created:', message.task.taskId); + * break; + * case 'taskStatus': + * console.log('Task status:', message.task.status); + * break; + * case 'result': + * console.log('User action:', message.result.action); + * break; + * case 'error': + * console.error('Error:', message.error); + * break; + * } + * } + * ``` + * + * @param params - The elicitation request parameters + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + elicitInputStream( + params: ElicitRequestFormParams | ElicitRequestURLParams, + options?: RequestOptions + ): AsyncGenerator, void, void> { + // Access client capabilities via the server + const clientCapabilities = this._server.getClientCapabilities(); + const mode = params.mode ?? 'form'; + + // Capability check based on mode + switch (mode) { + case 'url': { + if (!clientCapabilities?.elicitation?.url) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); + } + break; + } + case 'form': { + if (!clientCapabilities?.elicitation?.form) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); + } + break; + } + } + + // Normalize params to ensure mode is set + const normalizedParams = mode === 'form' && params.mode !== 'form' ? { ...params, mode: 'form' } : params; + return this.requestStream( + { + method: 'elicitation/create', + params: normalizedParams + }, + options + ) as AsyncGenerator, void, void>; + } + + /** + * Gets the current status of a task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * @returns The task status + * + * @experimental + */ + async getTask(taskId: string, options?: RequestOptions): Promise { + return this._module.getTask({ taskId }, options); + } + + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * @returns The task result. The payload structure matches the result type of the + * original request (e.g., a `tools/call` task returns a `CallToolResult`). + * + * @experimental + */ + async getTaskResult(taskId: string, options?: RequestOptions): Promise { + return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, options); + } + + /** + * Lists tasks with optional pagination. + * + * @param cursor - Optional pagination cursor + * @param options - Optional request options + * @returns List of tasks with optional next cursor + * + * @experimental + */ + async listTasks(cursor?: string, options?: RequestOptions): Promise { + return this._module.listTasks(cursor ? { cursor } : undefined, options); + } + + /** + * Cancels a running task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * + * @experimental + */ + async cancelTask(taskId: string, options?: RequestOptions): Promise { + return this._module.cancelTask({ taskId }, options); + } +} diff --git a/packages/server/src/fromJsonSchema.ts b/packages/server/src/fromJsonSchema.ts new file mode 100644 index 0000000..180ef2d --- /dev/null +++ b/packages/server/src/fromJsonSchema.ts @@ -0,0 +1,9 @@ +import type { JsonSchemaType, jsonSchemaValidator, StandardSchemaWithJSON } from '@modelcontextprotocol/core'; +import { fromJsonSchema as coreFromJsonSchema } from '@modelcontextprotocol/core'; +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; + +let _defaultValidator: jsonSchemaValidator | undefined; + +export function fromJsonSchema(schema: JsonSchemaType, validator?: jsonSchemaValidator): StandardSchemaWithJSON { + return coreFromJsonSchema(schema, validator ?? (_defaultValidator ??= new DefaultJsonSchemaValidator())); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..95566bb --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,52 @@ +// Public API for @modelcontextprotocol/server. +// +// This file defines the complete public surface. It consists of: +// - Package-specific exports: listed explicitly below (named imports) +// - Protocol-level types: re-exported from @modelcontextprotocol/core/public +// +// Any new export added here becomes public API. Use named exports, not wildcards. + +export type { CompletableSchema, CompleteCallback } from './server/completable.js'; +export { completable, isCompletable } from './server/completable.js'; +export type { + AnyToolHandler, + BaseToolCallback, + CompleteResourceTemplateCallback, + ListResourcesCallback, + PromptCallback, + ReadResourceCallback, + ReadResourceTemplateCallback, + RegisteredPrompt, + RegisteredResource, + RegisteredResourceTemplate, + RegisteredTool, + ResourceMetadata, + ToolCallback +} from './server/mcp.js'; +export { McpServer, ResourceTemplate } from './server/mcp.js'; +export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation.js'; +export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; +export type { ServerOptions } from './server/server.js'; +export { Server } from './server/server.js'; +// StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node +// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a +// consistent shape across packages. +export type { + EventId, + EventStore, + HandleRequestOptions, + StreamId, + WebStandardStreamableHTTPServerTransportOptions +} from './server/streamableHttp.js'; +export { WebStandardStreamableHTTPServerTransport } from './server/streamableHttp.js'; + +// experimental exports +export type { CreateTaskRequestHandler, TaskRequestHandler, ToolTaskHandler } from './experimental/tasks/interfaces.js'; +export { ExperimentalMcpServerTasks } from './experimental/tasks/mcpServer.js'; +export { ExperimentalServerTasks } from './experimental/tasks/server.js'; + +// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator) +export { fromJsonSchema } from './fromJsonSchema.js'; + +// re-export curated public API from core +export * from '@modelcontextprotocol/core/public'; diff --git a/packages/server/src/server/completable.examples.ts b/packages/server/src/server/completable.examples.ts new file mode 100644 index 0000000..b0655d2 --- /dev/null +++ b/packages/server/src/server/completable.examples.ts @@ -0,0 +1,46 @@ +/** + * Type-checked examples for `completable.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import * as z from 'zod/v4'; + +import { completable } from './completable.js'; +import { McpServer } from './mcp.js'; + +/** + * Example: Using completable() in a prompt registration. + */ +function completable_basicUsage() { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + //#region completable_basicUsage + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + argsSchema: z.object({ + language: completable(z.string().describe('Programming language'), value => + ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + ) + }) + }, + ({ language }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Review this ${language} code.` + } + } + ] + }) + ); + //#endregion completable_basicUsage + return server; +} diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts new file mode 100644 index 0000000..82300f7 --- /dev/null +++ b/packages/server/src/server/completable.ts @@ -0,0 +1,74 @@ +import type { StandardSchemaV1 } from '@modelcontextprotocol/core'; + +export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); + +export type CompleteCallback = ( + value: StandardSchemaV1.InferInput, + context?: { + arguments?: Record; + } +) => StandardSchemaV1.InferInput[] | Promise[]>; + +export type CompletableMeta = { + complete: CompleteCallback; +}; + +export type CompletableSchema = T & { + [COMPLETABLE_SYMBOL]: CompletableMeta; +}; + +/** + * Wraps a schema to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. + * + * @example + * ```ts source="./completable.examples.ts#completable_basicUsage" + * server.registerPrompt( + * 'review-code', + * { + * title: 'Code Review', + * argsSchema: z.object({ + * language: completable(z.string().describe('Programming language'), value => + * ['typescript', 'javascript', 'python', 'rust', 'go'].filter(lang => lang.startsWith(value)) + * ) + * }) + * }, + * ({ language }) => ({ + * messages: [ + * { + * role: 'user' as const, + * content: { + * type: 'text' as const, + * text: `Review this ${language} code.` + * } + * } + * ] + * }) + * ); + * ``` + * + * @see {@linkcode server/mcp.McpServer.registerPrompt | McpServer.registerPrompt} for using completable schemas in prompt argument definitions + */ +export function completable(schema: T, complete: CompleteCallback): CompletableSchema { + Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { + value: { complete } as CompletableMeta, + enumerable: false, + writable: false, + configurable: false + }); + return schema as CompletableSchema; +} + +/** + * Checks if a schema is completable (has completion metadata). + */ +export function isCompletable(schema: unknown): schema is CompletableSchema { + return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object); +} + +/** + * Gets the completer callback from a completable schema, if it exists. + */ +export function getCompleter(schema: T): CompleteCallback | undefined { + const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta })[COMPLETABLE_SYMBOL]; + return meta?.complete as CompleteCallback | undefined; +} diff --git a/packages/server/src/server/mcp.examples.ts b/packages/server/src/server/mcp.examples.ts new file mode 100644 index 0000000..740c1bf --- /dev/null +++ b/packages/server/src/server/mcp.examples.ts @@ -0,0 +1,145 @@ +/** + * Type-checked examples for `mcp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import type { CallToolResult } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; + +import { McpServer } from './mcp.js'; +import { StdioServerTransport } from './stdio.js'; + +/** + * Example: Creating a new McpServer. + */ +function McpServer_basicUsage() { + //#region McpServer_basicUsage + const server = new McpServer({ + name: 'my-server', + version: '1.0.0' + }); + //#endregion McpServer_basicUsage + return server; +} + +/** + * Example: Registering a tool with inputSchema and outputSchema. + */ +function McpServer_registerTool_basic(server: McpServer) { + //#region McpServer_registerTool_basic + server.registerTool( + 'calculate-bmi', + { + title: 'BMI Calculator', + description: 'Calculate Body Mass Index', + inputSchema: z.object({ + weightKg: z.number(), + heightM: z.number() + }), + outputSchema: z.object({ bmi: z.number() }) + }, + async ({ weightKg, heightM }) => { + const output = { bmi: weightKg / (heightM * heightM) }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + //#endregion McpServer_registerTool_basic +} + +/** + * Example: Registering a static resource at a fixed URI. + */ +function McpServer_registerResource_static(server: McpServer) { + //#region McpServer_registerResource_static + server.registerResource( + 'config', + 'config://app', + { + title: 'Application Config', + mimeType: 'text/plain' + }, + async uri => ({ + contents: [{ uri: uri.href, text: 'App configuration here' }] + }) + ); + //#endregion McpServer_registerResource_static +} + +/** + * Example: Registering a prompt with an argument schema. + */ +function McpServer_registerPrompt_basic(server: McpServer) { + //#region McpServer_registerPrompt_basic + server.registerPrompt( + 'review-code', + { + title: 'Code Review', + description: 'Review code for best practices', + argsSchema: z.object({ code: z.string() }) + }, + ({ code }) => ({ + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text: `Please review this code:\n\n${code}` + } + } + ] + }) + ); + //#endregion McpServer_registerPrompt_basic +} + +/** + * Example: Connecting an McpServer to a stdio transport. + */ +async function McpServer_connect_stdio() { + //#region McpServer_connect_stdio + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion McpServer_connect_stdio +} + +/** + * Example: Sending a log message to the client. + */ +async function McpServer_sendLoggingMessage_basic(server: McpServer) { + //#region McpServer_sendLoggingMessage_basic + await server.sendLoggingMessage({ + level: 'info', + data: 'Processing complete' + }); + //#endregion McpServer_sendLoggingMessage_basic +} + +/** + * Example: Logging from inside a tool handler via ctx.mcpReq.log(). + */ +function McpServer_registerTool_logging(server: McpServer) { + //#region McpServer_registerTool_logging + server.registerTool( + 'fetch-data', + { + description: 'Fetch data from an API', + inputSchema: z.object({ url: z.string() }) + }, + async ({ url }, ctx): Promise => { + await ctx.mcpReq.log('info', `Fetching ${url}`); + const res = await fetch(url); + await ctx.mcpReq.log('debug', `Response status: ${res.status}`); + const text = await res.text(); + return { content: [{ type: 'text', text }] }; + } + ); + //#endregion McpServer_registerTool_logging +} diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts new file mode 100644 index 0000000..fb45fd5 --- /dev/null +++ b/packages/server/src/server/mcp.ts @@ -0,0 +1,1397 @@ +import type { + BaseMetadata, + CallToolRequest, + CallToolResult, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, + CompleteResult, + CreateTaskResult, + CreateTaskServerContext, + GetPromptResult, + Implementation, + ListPromptsResult, + ListResourcesResult, + ListToolsResult, + LoggingMessageNotification, + Prompt, + PromptReference, + ReadResourceResult, + Resource, + ResourceTemplateReference, + Result, + ServerContext, + StandardSchemaWithJSON, + Tool, + ToolAnnotations, + ToolExecution, + Transport, + Variables +} from '@modelcontextprotocol/core'; +import { + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + normalizeRawShapeSchema, + promptArgumentsFromStandardSchema, + ProtocolError, + ProtocolErrorCode, + standardSchemaToJsonSchema, + UriTemplate, + validateAndWarnToolName, + validateStandardSchema +} from '@modelcontextprotocol/core'; +import type * as z from 'zod/v4'; + +import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; +import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcpServer.js'; +import { getCompleter, isCompletable } from './completable.js'; +import type { ServerOptions } from './server.js'; +import { Server } from './server.js'; + +/** + * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. + * For advanced usage (like sending notifications or setting custom request handlers), use the underlying + * {@linkcode Server} instance available via the {@linkcode McpServer.server | server} property. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_basicUsage" + * const server = new McpServer({ + * name: 'my-server', + * version: '1.0.0' + * }); + * ``` + */ +export class McpServer { + /** + * The underlying {@linkcode Server} instance, useful for advanced operations like sending notifications. + */ + public readonly server: Server; + + private _registeredResources: { [uri: string]: RegisteredResource } = {}; + private _registeredResourceTemplates: { + [name: string]: RegisteredResourceTemplate; + } = {}; + private _registeredTools: { [name: string]: RegisteredTool } = {}; + private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private _experimental?: { tasks: ExperimentalMcpServerTasks }; + + constructor(serverInfo: Implementation, options?: ServerOptions) { + this.server = new Server(serverInfo, options); + } + + /** + * Access experimental features. + * + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + get experimental(): { tasks: ExperimentalMcpServerTasks } { + if (!this._experimental) { + this._experimental = { + tasks: new ExperimentalMcpServerTasks(this) + }; + } + return this._experimental; + } + + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * + * The `server` object assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_connect_stdio" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new StdioServerTransport(); + * await server.connect(transport); + * ``` + */ + async connect(transport: Transport): Promise { + return await this.server.connect(transport); + } + + /** + * Closes the connection. + */ + async close(): Promise { + await this.server.close(); + } + + private _toolHandlersInitialized = false; + + private setToolRequestHandlers() { + if (this._toolHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler('tools/list'); + this.server.assertCanSetRequestHandler('tools/call'); + + this.server.registerCapabilities({ + tools: { + listChanged: this.server.getCapabilities().tools?.listChanged ?? true + } + }); + + this.server.setRequestHandler( + 'tools/list', + (): ListToolsResult => ({ + tools: Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map(([name, tool]): Tool => { + const toolDefinition: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + execution: tool.execution, + _meta: tool._meta + }; + + if (tool.outputSchema) { + toolDefinition.outputSchema = standardSchemaToJsonSchema(tool.outputSchema, 'output') as Tool['outputSchema']; + } + + return toolDefinition; + }) + }) + ); + + this.server.setRequestHandler('tools/call', async (request, ctx): Promise => { + const tool = this._registeredTools[request.params.name]; + if (!tool) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); + } + + try { + const isTaskRequest = !!request.params.task; + const taskSupport = tool.execution?.taskSupport; + const isTaskHandler = 'createTask' in (tool.handler as AnyToolHandler); + + // Validate task hint configuration + if ((taskSupport === 'required' || taskSupport === 'optional') && !isTaskHandler) { + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Tool ${request.params.name} has taskSupport '${taskSupport}' but was not registered with registerToolTask` + ); + } + + // Handle taskSupport 'required' without task augmentation + if (taskSupport === 'required' && !isTaskRequest) { + throw new ProtocolError( + ProtocolErrorCode.MethodNotFound, + `Tool ${request.params.name} requires task augmentation (taskSupport: 'required')` + ); + } + + // Handle taskSupport 'optional' without task augmentation - automatic polling + if (taskSupport === 'optional' && !isTaskRequest && isTaskHandler) { + return await this.handleAutomaticTaskPolling(tool, request, ctx); + } + + // Normal execution path + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const result = await this.executeToolHandler(tool, args, ctx); + + // Return CreateTaskResult immediately for task requests + if (isTaskRequest) { + return result; + } + + // Validate output schema for non-task requests + await this.validateToolOutput(tool, result, request.params.name); + return result; + } catch (error) { + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { + throw error; // Return the error to the caller without wrapping in CallToolResult + } + return this.createToolError(error instanceof Error ? error.message : String(error)); + } + }); + + this._toolHandlersInitialized = true; + } + + /** + * Creates a tool error result. + * + * @param errorMessage - The error message. + * @returns The tool error result. + */ + private createToolError(errorMessage: string): CallToolResult { + return { + content: [ + { + type: 'text', + text: errorMessage + } + ], + isError: true + }; + } + + /** + * Validates tool input arguments against the tool's input schema. + */ + private async validateToolInput< + ToolType extends RegisteredTool, + Args extends ToolType['inputSchema'] extends infer InputSchema + ? InputSchema extends StandardSchemaWithJSON + ? StandardSchemaWithJSON.InferOutput + : undefined + : undefined + >(tool: ToolType, args: Args, toolName: string): Promise { + if (!tool.inputSchema) { + return undefined as Args; + } + + const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {}); + if (!parseResult.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}` + ); + } + + return parseResult.data as unknown as Args; + } + + /** + * Validates tool output against the tool's output schema. + */ + private async validateToolOutput(tool: RegisteredTool, result: CallToolResult | CreateTaskResult, toolName: string): Promise { + if (!tool.outputSchema) { + return; + } + + // Only validate CallToolResult, not CreateTaskResult + if (!('content' in result)) { + return; + } + + if (result.isError) { + return; + } + + if (!result.structuredContent) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Tool ${toolName} has an output schema but no structured content was provided` + ); + } + + // if the tool has an output schema, validate structured content + const parseResult = await validateStandardSchema(tool.outputSchema, result.structuredContent); + if (!parseResult.success) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Output validation error: Invalid structured content for tool ${toolName}: ${parseResult.error}` + ); + } + } + + /** + * Executes a tool handler (either regular or task-based). + */ + private async executeToolHandler(tool: RegisteredTool, args: unknown, ctx: ServerContext): Promise { + // Executor encapsulates handler invocation with proper types + return tool.executor(args, ctx); + } + + /** + * Handles automatic task polling for tools with `taskSupport` `'optional'`. + */ + private async handleAutomaticTaskPolling( + tool: RegisteredTool, + request: RequestT, + ctx: ServerContext + ): Promise { + if (!ctx.task?.store) { + throw new Error('No task store provided for task-capable tool.'); + } + + // Validate input and create task using the executor + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const createTaskResult = (await tool.executor(args, ctx)) as CreateTaskResult; + + // Poll until completion + const taskId = createTaskResult.task.taskId; + let task = createTaskResult.task; + const pollInterval = task.pollInterval ?? 5000; + + while (task.status !== 'completed' && task.status !== 'failed' && task.status !== 'cancelled') { + await new Promise(resolve => setTimeout(resolve, pollInterval)); + const updatedTask = await ctx.task.store.getTask(taskId); + if (!updatedTask) { + throw new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} not found during polling`); + } + task = updatedTask; + } + + // Return the final result + return (await ctx.task.store.getTaskResult(taskId)) as CallToolResult; + } + + private _completionHandlerInitialized = false; + + private setCompletionRequestHandler() { + if (this._completionHandlerInitialized) { + return; + } + + this.server.assertCanSetRequestHandler('completion/complete'); + + this.server.registerCapabilities({ + completions: {} + }); + + this.server.setRequestHandler('completion/complete', async (request): Promise => { + switch (request.params.ref.type) { + case 'ref/prompt': { + assertCompleteRequestPrompt(request); + return this.handlePromptCompletion(request, request.params.ref); + } + + case 'ref/resource': { + assertCompleteRequestResourceTemplate(request); + return this.handleResourceCompletion(request, request.params.ref); + } + + default: { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid completion reference: ${request.params.ref}`); + } + } + }); + + this._completionHandlerInitialized = true; + } + + private async handlePromptCompletion(request: CompleteRequestPrompt, ref: PromptReference): Promise { + const prompt = this._registeredPrompts[ref.name]; + if (!prompt) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`); + } + + if (!prompt.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); + } + + if (!prompt.argsSchema) { + return EMPTY_COMPLETION_RESULT; + } + + const promptShape = getSchemaShape(prompt.argsSchema); + const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]); + if (!isCompletable(field)) { + return EMPTY_COMPLETION_RESULT; + } + + const completer = getCompleter(field); + if (!completer) { + return EMPTY_COMPLETION_RESULT; + } + + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + private async handleResourceCompletion( + request: CompleteRequestResourceTemplate, + ref: ResourceTemplateReference + ): Promise { + const template = Object.values(this._registeredResourceTemplates).find(t => t.resourceTemplate.uriTemplate.toString() === ref.uri); + + if (!template) { + if (this._registeredResources[ref.uri]) { + // Attempting to autocomplete a fixed resource URI is not an error in the spec (but probably should be). + return EMPTY_COMPLETION_RESULT; + } + + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${request.params.ref.uri} not found`); + } + + const completer = template.resourceTemplate.completeCallback(request.params.argument.name); + if (!completer) { + return EMPTY_COMPLETION_RESULT; + } + + const suggestions = await completer(request.params.argument.value, request.params.context); + return createCompletionResult(suggestions); + } + + private _resourceHandlersInitialized = false; + + private setResourceRequestHandlers() { + if (this._resourceHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler('resources/list'); + this.server.assertCanSetRequestHandler('resources/templates/list'); + this.server.assertCanSetRequestHandler('resources/read'); + + this.server.registerCapabilities({ + resources: { + listChanged: this.server.getCapabilities().resources?.listChanged ?? true + } + }); + + this.server.setRequestHandler('resources/list', async (_request, ctx) => { + const resources = Object.entries(this._registeredResources) + .filter(([_, resource]) => resource.enabled) + .map(([uri, resource]) => ({ + uri, + name: resource.name, + ...resource.metadata + })); + + const templateResources: Resource[] = []; + for (const template of Object.values(this._registeredResourceTemplates)) { + if (!template.resourceTemplate.listCallback) { + continue; + } + + const result = await template.resourceTemplate.listCallback(ctx); + for (const resource of result.resources) { + templateResources.push({ + ...template.metadata, + // the defined resource metadata should override the template metadata if present + ...resource + }); + } + } + + return { resources: [...resources, ...templateResources] }; + }); + + this.server.setRequestHandler('resources/templates/list', async () => { + const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ + name, + uriTemplate: template.resourceTemplate.uriTemplate.toString(), + ...template.metadata + })); + + return { resourceTemplates }; + }); + + this.server.setRequestHandler('resources/read', async (request, ctx) => { + const uri = new URL(request.params.uri); + + // First check for exact resource match + const resource = this._registeredResources[uri.toString()]; + if (resource) { + if (!resource.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource ${uri} disabled`); + } + return resource.readCallback(uri, ctx); + } + + // Then check templates + for (const template of Object.values(this._registeredResourceTemplates)) { + const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); + if (variables) { + return template.readCallback(uri, variables, ctx); + } + } + + throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`); + }); + + this._resourceHandlersInitialized = true; + } + + private _promptHandlersInitialized = false; + + private setPromptRequestHandlers() { + if (this._promptHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler('prompts/list'); + this.server.assertCanSetRequestHandler('prompts/get'); + + this.server.registerCapabilities({ + prompts: { + listChanged: this.server.getCapabilities().prompts?.listChanged ?? true + } + }); + + this.server.setRequestHandler( + 'prompts/list', + (): ListPromptsResult => ({ + prompts: Object.entries(this._registeredPrompts) + .filter(([, prompt]) => prompt.enabled) + .map(([name, prompt]): Prompt => { + return { + name, + title: prompt.title, + description: prompt.description, + arguments: prompt.argsSchema ? promptArgumentsFromStandardSchema(prompt.argsSchema) : undefined, + _meta: prompt._meta + }; + }) + }) + ); + + this.server.setRequestHandler('prompts/get', async (request, ctx): Promise => { + const prompt = this._registeredPrompts[request.params.name]; + if (!prompt) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); + } + + if (!prompt.enabled) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); + } + + // Handler encapsulates parsing and callback invocation with proper types + return prompt.handler(request.params.arguments, ctx); + }); + + this._promptHandlersInitialized = true; + } + + /** + * Registers a resource with a config object and callback. + * For static resources, use a URI string. For dynamic resources, use a {@linkcode ResourceTemplate}. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerResource_static" + * server.registerResource( + * 'config', + * 'config://app', + * { + * title: 'Application Config', + * mimeType: 'text/plain' + * }, + * async uri => ({ + * contents: [{ uri: uri.href, text: 'App configuration here' }] + * }) + * ); + * ``` + */ + registerResource(name: string, uriOrTemplate: string, config: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + registerResource( + name: string, + uriOrTemplate: ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; + registerResource( + name: string, + uriOrTemplate: string | ResourceTemplate, + config: ResourceMetadata, + readCallback: ReadResourceCallback | ReadResourceTemplateCallback + ): RegisteredResource | RegisteredResourceTemplate { + if (typeof uriOrTemplate === 'string') { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource = this._createRegisteredResource( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceCallback + ); + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResource; + } else { + if (this._registeredResourceTemplates[name]) { + throw new Error(`Resource template ${name} is already registered`); + } + + const registeredResourceTemplate = this._createRegisteredResourceTemplate( + name, + (config as BaseMetadata).title, + uriOrTemplate, + config, + readCallback as ReadResourceTemplateCallback + ); + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + + private _createRegisteredResource( + name: string, + title: string | undefined, + uri: string, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceCallback + ): RegisteredResource { + const registeredResource: RegisteredResource = { + name, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResource.update({ enabled: false }), + enable: () => registeredResource.update({ enabled: true }), + remove: () => registeredResource.update({ uri: null }), + update: updates => { + if (updates.uri !== undefined && updates.uri !== uri) { + delete this._registeredResources[uri]; + if (updates.uri) this._registeredResources[updates.uri] = registeredResource; + } + if (updates.name !== undefined) registeredResource.name = updates.name; + if (updates.title !== undefined) registeredResource.title = updates.title; + if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; + if (updates.callback !== undefined) registeredResource.readCallback = updates.callback; + if (updates.enabled !== undefined) registeredResource.enabled = updates.enabled; + this.sendResourceListChanged(); + } + }; + this._registeredResources[uri] = registeredResource; + return registeredResource; + } + + private _createRegisteredResourceTemplate( + name: string, + title: string | undefined, + template: ResourceTemplate, + metadata: ResourceMetadata | undefined, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate { + const registeredResourceTemplate: RegisteredResourceTemplate = { + resourceTemplate: template, + title, + metadata, + readCallback, + enabled: true, + disable: () => registeredResourceTemplate.update({ enabled: false }), + enable: () => registeredResourceTemplate.update({ enabled: true }), + remove: () => registeredResourceTemplate.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this._registeredResourceTemplates[name]; + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; + } + if (updates.title !== undefined) registeredResourceTemplate.title = updates.title; + if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; + if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; + if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback; + if (updates.enabled !== undefined) registeredResourceTemplate.enabled = updates.enabled; + this.sendResourceListChanged(); + } + }; + this._registeredResourceTemplates[name] = registeredResourceTemplate; + + // If the resource template has any completion callbacks, enable completions capability + const variableNames = template.uriTemplate.variableNames; + const hasCompleter = Array.isArray(variableNames) && variableNames.some(v => !!template.completeCallback(v)); + if (hasCompleter) { + this.setCompletionRequestHandler(); + } + + return registeredResourceTemplate; + } + + private _createRegisteredPrompt( + name: string, + title: string | undefined, + description: string | undefined, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback, + _meta: Record | undefined + ): RegisteredPrompt { + // Track current schema and callback for handler regeneration + let currentArgsSchema = argsSchema; + let currentCallback = callback; + + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema, + _meta, + handler: createPromptHandler(name, argsSchema, callback), + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + delete this._registeredPrompts[name]; + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; + } + if (updates.title !== undefined) registeredPrompt.title = updates.title; + if (updates.description !== undefined) registeredPrompt.description = updates.description; + if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; + + // Track if we need to regenerate the handler + let needsHandlerRegen = false; + if (updates.argsSchema !== undefined) { + registeredPrompt.argsSchema = updates.argsSchema; + currentArgsSchema = updates.argsSchema; + needsHandlerRegen = true; + } + if (updates.callback !== undefined) { + currentCallback = updates.callback as PromptCallback; + needsHandlerRegen = true; + } + if (needsHandlerRegen) { + registeredPrompt.handler = createPromptHandler(name, currentArgsSchema, currentCallback); + } + + if (updates.enabled !== undefined) registeredPrompt.enabled = updates.enabled; + this.sendPromptListChanged(); + } + }; + this._registeredPrompts[name] = registeredPrompt; + + // If any argument uses a Completable schema, enable completions capability + if (argsSchema) { + const shape = getSchemaShape(argsSchema); + if (shape) { + const hasCompletable = Object.values(shape).some(field => { + const inner = unwrapOptionalSchema(field); + return isCompletable(inner); + }); + if (hasCompletable) { + this.setCompletionRequestHandler(); + } + } + } + + return registeredPrompt; + } + + private _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: StandardSchemaWithJSON | undefined, + outputSchema: StandardSchemaWithJSON | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool { + // Validate tool name according to SEP specification + validateAndWarnToolName(name); + + // Track current handler for executor regeneration + let currentHandler = handler; + + const registeredTool: RegisteredTool = { + title, + description, + inputSchema, + outputSchema, + annotations, + execution, + _meta, + handler: handler, + executor: createToolExecutor(inputSchema, handler), + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: updates => { + if (updates.name !== undefined && updates.name !== name) { + if (typeof updates.name === 'string') { + validateAndWarnToolName(updates.name); + } + delete this._registeredTools[name]; + if (updates.name) this._registeredTools[updates.name] = registeredTool; + } + if (updates.title !== undefined) registeredTool.title = updates.title; + if (updates.description !== undefined) registeredTool.description = updates.description; + + // Track if we need to regenerate the executor + let needsExecutorRegen = false; + if (updates.paramsSchema !== undefined) { + registeredTool.inputSchema = updates.paramsSchema; + needsExecutorRegen = true; + } + if (updates.callback !== undefined) { + registeredTool.handler = updates.callback; + currentHandler = updates.callback as AnyToolHandler; + needsExecutorRegen = true; + } + if (needsExecutorRegen) { + registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler); + } + + if (updates.outputSchema !== undefined) registeredTool.outputSchema = updates.outputSchema; + if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; + if (updates._meta !== undefined) registeredTool._meta = updates._meta; + if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; + this.sendToolListChanged(); + } + }; + this._registeredTools[name] = registeredTool; + + this.setToolRequestHandlers(); + this.sendToolListChanged(); + + return registeredTool; + } + + /** + * Registers a tool with a config object and callback. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerTool_basic" + * server.registerTool( + * 'calculate-bmi', + * { + * title: 'BMI Calculator', + * description: 'Calculate Body Mass Index', + * inputSchema: z.object({ + * weightKg: z.number(), + * heightM: z.number() + * }), + * outputSchema: z.object({ bmi: z.number() }) + * }, + * async ({ weightKg, heightM }) => { + * const output = { bmi: weightKg / (heightM * heightM) }; + * return { + * content: [{ type: 'text', text: JSON.stringify(output) }], + * structuredContent: output + * }; + * } + * ); + * ``` + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback + ): RegisteredTool; + /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `inputSchema`/`outputSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: LegacyToolCallback + ): RegisteredTool; + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON | ZodRawShape; + outputSchema?: StandardSchemaWithJSON | ZodRawShape; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback | LegacyToolCallback + ): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } + + const { title, description, inputSchema, outputSchema, annotations, _meta } = config; + + return this._createRegisteredTool( + name, + title, + description, + normalizeRawShapeSchema(inputSchema), + normalizeRawShapeSchema(outputSchema), + annotations, + { taskSupport: 'forbidden' }, + _meta, + cb as ToolCallback + ); + } + + /** + * Registers a prompt with a config object and callback. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_registerPrompt_basic" + * server.registerPrompt( + * 'review-code', + * { + * title: 'Code Review', + * description: 'Review code for best practices', + * argsSchema: z.object({ code: z.string() }) + * }, + * ({ code }) => ({ + * messages: [ + * { + * role: 'user' as const, + * content: { + * type: 'text' as const, + * text: `Please review this code:\n\n${code}` + * } + * } + * ] + * }) + * ); + * ``` + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, + cb: PromptCallback + ): RegisteredPrompt; + /** @deprecated Wrap with `z.object({...})` instead. Raw-shape form: `argsSchema` may be a plain `{ field: z.string() }` record; it is auto-wrapped with `z.object()`. */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + }, + cb: LegacyPromptCallback + ): RegisteredPrompt; + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON | ZodRawShape; + _meta?: Record; + }, + cb: PromptCallback | LegacyPromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, argsSchema, _meta } = config; + + const registeredPrompt = this._createRegisteredPrompt( + name, + title, + description, + normalizeRawShapeSchema(argsSchema), + cb as PromptCallback, + _meta + ); + + this.setPromptRequestHandlers(); + this.sendPromptListChanged(); + + return registeredPrompt; + } + + /** + * Checks if the server is connected to a transport. + * @returns `true` if the server is connected + */ + isConnected() { + return this.server.transport !== undefined; + } + + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON-RPC message. + * @see {@linkcode LoggingMessageNotification} + * @param params + * @param sessionId Optional for stateless transports and backward compatibility. + * + * @example + * ```ts source="./mcp.examples.ts#McpServer_sendLoggingMessage_basic" + * await server.sendLoggingMessage({ + * level: 'info', + * data: 'Processing complete' + * }); + * ``` + */ + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { + return this.server.sendLoggingMessage(params, sessionId); + } + /** + * Sends a resource list changed event to the client, if connected. + */ + sendResourceListChanged() { + if (this.isConnected()) { + this.server.sendResourceListChanged(); + } + } + + /** + * Sends a tool list changed event to the client, if connected. + */ + sendToolListChanged() { + if (this.isConnected()) { + this.server.sendToolListChanged(); + } + } + + /** + * Sends a prompt list changed event to the client, if connected. + */ + sendPromptListChanged() { + if (this.isConnected()) { + this.server.sendPromptListChanged(); + } + } +} + +/** + * A callback to complete one variable within a resource template's URI template. + */ +export type CompleteResourceTemplateCallback = ( + value: string, + context?: { + arguments?: Record; + } +) => string[] | Promise; + +/** + * A resource template combines a URI pattern with optional functionality to enumerate + * all resources matching that pattern. + */ +export class ResourceTemplate { + private _uriTemplate: UriTemplate; + + constructor( + uriTemplate: string | UriTemplate, + private _callbacks: { + /** + * A callback to list all resources matching this template. This is required to be specified, even if `undefined`, to avoid accidentally forgetting resource listing. + */ + list: ListResourcesCallback | undefined; + + /** + * An optional callback to autocomplete variables within the URI template. Useful for clients and users to discover possible values. + */ + complete?: { + [variable: string]: CompleteResourceTemplateCallback; + }; + } + ) { + this._uriTemplate = typeof uriTemplate === 'string' ? new UriTemplate(uriTemplate) : uriTemplate; + } + + /** + * Gets the URI template pattern. + */ + get uriTemplate(): UriTemplate { + return this._uriTemplate; + } + + /** + * Gets the list callback, if one was provided. + */ + get listCallback(): ListResourcesCallback | undefined { + return this._callbacks.list; + } + + /** + * Gets the callback for completing a specific URI template variable, if one was provided. + */ + completeCallback(variable: string): CompleteResourceTemplateCallback | undefined { + return this._callbacks.complete?.[variable]; + } +} + +/** + * A plain record of Zod field schemas, e.g. `{ name: z.string() }`. Accepted by + * `registerTool`/`registerPrompt` as a shorthand; auto-wrapped with `z.object()`. + * Zod schemas only — `z.object()` cannot wrap other Standard Schema libraries. + */ +export type ZodRawShape = Record; + +/** Infers the parsed-output type of a {@linkcode ZodRawShape}. */ +export type InferRawShape = z.infer>; + +/** {@linkcode ToolCallback} variant used when `inputSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyToolCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => CallToolResult | Promise + : (ctx: ServerContext) => CallToolResult | Promise; + +/** {@linkcode PromptCallback} variant used when `argsSchema` is a {@linkcode ZodRawShape}. */ +export type LegacyPromptCallback = Args extends ZodRawShape + ? (args: InferRawShape, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + +export type BaseToolCallback< + SendResultT extends Result, + Ctx extends ServerContext, + Args extends StandardSchemaWithJSON | undefined +> = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: Ctx) => SendResultT | Promise + : (ctx: Ctx) => SendResultT | Promise; + +/** + * Callback for a tool handler registered with {@linkcode McpServer.registerTool}. + */ +export type ToolCallback = BaseToolCallback< + CallToolResult, + ServerContext, + Args +>; + +/** + * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). + */ +export type AnyToolHandler = ToolCallback | ToolTaskHandler; + +/** + * Internal executor type that encapsulates handler invocation with proper types. + */ +type ToolExecutor = (args: unknown, ctx: ServerContext) => Promise; + +export type RegisteredTool = { + title?: string; + description?: string; + inputSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + execution?: ToolExecution; + _meta?: Record; + handler: AnyToolHandler; + /** @hidden */ + executor: ToolExecutor; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: StandardSchemaWithJSON; + outputSchema?: StandardSchemaWithJSON; + annotations?: ToolAnnotations; + _meta?: Record; + callback?: ToolCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +/** + * Creates an executor that invokes the handler with the appropriate arguments. + * When `inputSchema` is defined, the handler is called with `(args, ctx)`. + * When `inputSchema` is undefined, the handler is called with just `(ctx)`. + */ +function createToolExecutor( + inputSchema: StandardSchemaWithJSON | undefined, + handler: AnyToolHandler +): ToolExecutor { + const isTaskHandler = 'createTask' in handler; + + if (isTaskHandler) { + const taskHandler = handler as TaskHandlerInternal; + return async (args, ctx) => { + if (!ctx.task?.store) { + throw new Error('No task store provided.'); + } + const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } }; + if (inputSchema) { + return taskHandler.createTask(args, taskCtx); + } + // When no inputSchema, call with just ctx (the handler expects (ctx) signature) + return (taskHandler.createTask as (ctx: CreateTaskServerContext) => CreateTaskResult | Promise)(taskCtx); + }; + } + + if (inputSchema) { + const callback = handler as ToolCallbackInternal; + return async (args, ctx) => callback(args, ctx); + } + + // When no inputSchema, call with just ctx (the handler expects (ctx) signature) + const callback = handler as (ctx: ServerContext) => CallToolResult | Promise; + return async (_args, ctx) => callback(ctx); +} + +const EMPTY_OBJECT_JSON_SCHEMA = { + type: 'object' as const, + properties: {} +}; + +/** + * Additional, optional information for annotating a resource. + */ +export type ResourceMetadata = Omit; + +/** + * Callback to list all resources matching a given template. + */ +export type ListResourcesCallback = (ctx: ServerContext) => ListResourcesResult | Promise; + +/** + * Callback to read a resource at a given URI. + */ +export type ReadResourceCallback = (uri: URL, ctx: ServerContext) => ReadResourceResult | Promise; + +export type RegisteredResource = { + name: string; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string; + title?: string; + uri?: string | null; + metadata?: ResourceMetadata; + callback?: ReadResourceCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +/** + * Callback to read a resource at a given URI, following a filled-in URI template. + */ +export type ReadResourceTemplateCallback = ( + uri: URL, + variables: Variables, + ctx: ServerContext +) => ReadResourceResult | Promise; + +export type RegisteredResourceTemplate = { + resourceTemplate: ResourceTemplate; + title?: string; + metadata?: ResourceMetadata; + readCallback: ReadResourceTemplateCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + template?: ResourceTemplate; + metadata?: ResourceMetadata; + callback?: ReadResourceTemplateCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +export type PromptCallback = Args extends StandardSchemaWithJSON + ? (args: StandardSchemaWithJSON.InferOutput, ctx: ServerContext) => GetPromptResult | Promise + : (ctx: ServerContext) => GetPromptResult | Promise; + +/** + * Internal handler type that encapsulates parsing and callback invocation. + * This allows type-safe handling without runtime type assertions. + */ +type PromptHandler = (args: Record | undefined, ctx: ServerContext) => Promise; + +type ToolCallbackInternal = (args: unknown, ctx: ServerContext) => CallToolResult | Promise; + +type TaskHandlerInternal = { + createTask: (args: unknown, ctx: CreateTaskServerContext) => CreateTaskResult | Promise; +}; + +export type RegisteredPrompt = { + title?: string; + description?: string; + argsSchema?: StandardSchemaWithJSON; + _meta?: Record; + /** @hidden */ + handler: PromptHandler; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + argsSchema?: Args; + _meta?: Record; + callback?: PromptCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +/** + * Creates a type-safe prompt handler that captures the schema and callback in a closure. + * This eliminates the need for type assertions at the call site. + */ +function createPromptHandler( + name: string, + argsSchema: StandardSchemaWithJSON | undefined, + callback: PromptCallback +): PromptHandler { + if (argsSchema) { + const typedCallback = callback as (args: unknown, ctx: ServerContext) => GetPromptResult | Promise; + + return async (args, ctx) => { + const parseResult = await validateStandardSchema(argsSchema, args); + if (!parseResult.success) { + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parseResult.error}`); + } + return typedCallback(parseResult.data, ctx); + }; + } else { + const typedCallback = callback as (ctx: ServerContext) => GetPromptResult | Promise; + + return async (_args, ctx) => { + return typedCallback(ctx); + }; + } +} + +function createCompletionResult(suggestions: readonly unknown[]): CompleteResult { + const values = suggestions.map(String).slice(0, 100); + return { + completion: { + values, + total: suggestions.length, + hasMore: suggestions.length > 100 + } + }; +} + +const EMPTY_COMPLETION_RESULT: CompleteResult = { + completion: { + values: [], + hasMore: false + } +}; + +/** @internal Gets the shape of a Zod object schema */ +function getSchemaShape(schema: unknown): Record | undefined { + const candidate = schema as { shape?: unknown }; + if (candidate.shape && typeof candidate.shape === 'object') { + return candidate.shape as Record; + } + return undefined; +} + +/** @internal Checks if a Zod schema is optional */ +function isOptionalSchema(schema: unknown): boolean { + const candidate = schema as { type?: string } | null | undefined; + return candidate?.type === 'optional'; +} + +/** @internal Unwraps an optional Zod schema */ +function unwrapOptionalSchema(schema: unknown): unknown { + if (!isOptionalSchema(schema)) { + return schema; + } + const candidate = schema as { def?: { innerType?: unknown } }; + return candidate.def?.innerType ?? schema; +} diff --git a/packages/server/src/server/middleware/hostHeaderValidation.examples.ts b/packages/server/src/server/middleware/hostHeaderValidation.examples.ts new file mode 100644 index 0000000..fd49d97 --- /dev/null +++ b/packages/server/src/server/middleware/hostHeaderValidation.examples.ts @@ -0,0 +1,20 @@ +/** + * Type-checked examples for `hostHeaderValidation.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { validateHostHeader } from './hostHeaderValidation.js'; + +/** + * Example: Validating a host header against allowed hosts. + */ +function hostHeaderValidationResponse_basicUsage(req: Request) { + //#region hostHeaderValidationResponse_basicUsage + const result = validateHostHeader(req.headers.get('host'), ['localhost']); + //#endregion hostHeaderValidationResponse_basicUsage + return result; +} diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000..a438bea --- /dev/null +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -0,0 +1,69 @@ +export type HostHeaderValidationResult = + | { ok: true; hostname: string } + | { + ok: false; + errorCode: 'missing_host' | 'invalid_host_header' | 'invalid_host'; + message: string; + hostHeader?: string; + hostname?: string; + }; + +/** + * Parse and validate a `Host` header against an allowlist of hostnames (port-agnostic). + * + * - Input host header may include a port (e.g. `localhost:3000`) or IPv6 brackets (e.g. `[::1]:3000`). + * - Allowlist items should be hostnames only (no ports). For IPv6, include brackets (e.g. `[::1]`). + */ +export function validateHostHeader(hostHeader: string | null | undefined, allowedHostnames: string[]): HostHeaderValidationResult { + if (!hostHeader) { + return { ok: false, errorCode: 'missing_host', message: 'Missing Host header' }; + } + + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return { ok: false, errorCode: 'invalid_host_header', message: `Invalid Host header: ${hostHeader}`, hostHeader }; + } + + if (!allowedHostnames.includes(hostname)) { + return { ok: false, errorCode: 'invalid_host', message: `Invalid Host: ${hostname}`, hostHeader, hostname }; + } + + return { ok: true, hostname }; +} + +/** + * Convenience allowlist for `localhost` DNS rebinding protection. + */ +export function localhostAllowedHostnames(): string[] { + return ['localhost', '127.0.0.1', '[::1]']; +} + +/** + * Web-standard `Request` helper for DNS rebinding protection. + * @example + * ```ts source="./hostHeaderValidation.examples.ts#hostHeaderValidationResponse_basicUsage" + * const result = validateHostHeader(req.headers.get('host'), ['localhost']); + * ``` + */ +export function hostHeaderValidationResponse(req: Request, allowedHostnames: string[]): Response | undefined { + const result = validateHostHeader(req.headers.get('host'), allowedHostnames); + if (result.ok) return undefined; + + return Response.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: result.message + }, + id: null + }, + { + status: 403, + headers: { 'Content-Type': 'application/json' } + } + ); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts new file mode 100644 index 0000000..f6a34f0 --- /dev/null +++ b/packages/server/src/server/server.ts @@ -0,0 +1,672 @@ +import type { + BaseContext, + ClientCapabilities, + CreateMessageRequest, + CreateMessageRequestParamsBase, + CreateMessageRequestParamsWithTools, + CreateMessageResult, + CreateMessageResultWithTools, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + Implementation, + InitializeRequest, + InitializeResult, + JSONRPCRequest, + JsonSchemaType, + jsonSchemaValidator, + ListRootsRequest, + LoggingLevel, + LoggingMessageNotification, + MessageExtraInfo, + NotificationMethod, + NotificationOptions, + ProtocolOptions, + RequestMethod, + RequestOptions, + ResourceUpdatedNotification, + Result, + ServerCapabilities, + ServerContext, + TaskManagerOptions, + ToolResultContent, + ToolUseContent +} from '@modelcontextprotocol/core'; +import { + assertClientRequestTaskCapability, + assertToolsCallTaskCapability, + CallToolRequestSchema, + CallToolResultSchema, + CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, + ElicitResultSchema, + EmptyResultSchema, + extractTaskManagerOptions, + LATEST_PROTOCOL_VERSION, + ListRootsResultSchema, + LoggingLevelSchema, + mergeCapabilities, + parseSchema, + Protocol, + ProtocolError, + ProtocolErrorCode, + SdkError, + SdkErrorCode +} from '@modelcontextprotocol/core'; +import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; + +import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; + +/** + * Extended tasks capability that includes runtime configuration (store, messageQueue). + * The runtime-only fields are stripped before advertising capabilities to clients. + */ +export type ServerTasksCapabilityWithRuntime = NonNullable & TaskManagerOptions; + +export type ServerOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this server. + */ + capabilities?: Omit & { + tasks?: ServerTasksCapabilityWithRuntime; + }; + + /** + * Optional instructions describing how to use the server and its features. + */ + instructions?: string; + + /** + * JSON Schema validator for elicitation response validation. + * + * The validator is used to validate user input returned from elicitation + * requests against the requested schema. + * + * @default {@linkcode DefaultJsonSchemaValidator} ({@linkcode index.AjvJsonSchemaValidator | AjvJsonSchemaValidator} on Node.js, `CfWorkerJsonSchemaValidator` on Cloudflare Workers) + */ + jsonSchemaValidator?: jsonSchemaValidator; +}; + +/** + * An MCP server on top of a pluggable transport. + * + * This server will automatically respond to the initialization flow as initiated from the client. + * + * @deprecated Use {@linkcode server/mcp.McpServer | McpServer} instead for the high-level API. Only use `Server` for advanced use cases. + */ +export class Server extends Protocol { + private _clientCapabilities?: ClientCapabilities; + private _clientVersion?: Implementation; + private _capabilities: ServerCapabilities; + private _instructions?: string; + private _jsonSchemaValidator: jsonSchemaValidator; + private _experimental?: { tasks: ExperimentalServerTasks }; + + /** + * Callback for when initialization has fully completed (i.e., the client has sent an `notifications/initialized` notification). + */ + oninitialized?: () => void; + + /** + * Initializes this server with the given name and version information. + */ + constructor( + private _serverInfo: Implementation, + options?: ServerOptions + ) { + super({ + ...options, + tasks: extractTaskManagerOptions(options?.capabilities?.tasks) + }); + this._capabilities = options?.capabilities ? { ...options.capabilities } : {}; + this._instructions = options?.instructions; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); + + // Strip runtime-only fields from advertised capabilities + if (options?.capabilities?.tasks) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } = + options.capabilities.tasks; + this._capabilities.tasks = wireCapabilities; + } + + this.setRequestHandler('initialize', request => this._oninitialize(request)); + this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); + + if (this._capabilities.logging) { + this._registerLoggingHandler(); + } + } + + private _registerLoggingHandler(): void { + this.setRequestHandler('logging/setLevel', async (request, ctx) => { + const transportSessionId: string | undefined = + ctx.sessionId || (ctx.http?.req?.headers.get('mcp-session-id') as string) || undefined; + const { level } = request.params; + const parseResult = parseSchema(LoggingLevelSchema, level); + if (parseResult.success) { + this._loggingLevels.set(transportSessionId, parseResult.data); + } + return {}; + }); + } + + protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { + // Only create http when there's actual HTTP transport info or auth info + const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; + return { + ...ctx, + mcpReq: { + ...ctx.mcpReq, + log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), + elicitInput: (params, options) => this.elicitInput(params, options), + requestSampling: (params, options) => this.createMessage(params, options) + }, + http: hasHttpInfo + ? { + ...ctx.http, + req: transportInfo?.request, + closeSSE: transportInfo?.closeSSEStream, + closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream + } + : undefined + }; + } + + /** + * Access experimental features. + * + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + get experimental(): { tasks: ExperimentalServerTasks } { + if (!this._experimental) { + this._experimental = { + tasks: new ExperimentalServerTasks(this) + }; + } + return this._experimental; + } + + // Map log levels by session id + private _loggingLevels = new Map(); + + // Map LogLevelSchema to severity index + private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + + // Is a message with the given level ignored in the log level set for the given session id? + private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { + const currentLevel = this._loggingLevels.get(sessionId); + return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; + }; + + /** + * Registers new capabilities. This can only be called before connecting to a transport. + * + * The new capabilities will be merged with any existing capabilities previously given (e.g., at initialization). + */ + public registerCapabilities(capabilities: ServerCapabilities): void { + if (this.transport) { + throw new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport'); + } + const hadLogging = !!this._capabilities.logging; + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + if (!hadLogging && this._capabilities.logging) { + this._registerLoggingHandler(); + } + } + + /** + * Enforces server-side validation for `tools/call` results regardless of how the + * handler was registered. + */ + protected override _wrapHandler( + method: string, + handler: (request: JSONRPCRequest, ctx: ServerContext) => Promise + ): (request: JSONRPCRequest, ctx: ServerContext) => Promise { + if (method !== 'tools/call') { + return handler; + } + return async (request, ctx) => { + const validatedRequest = parseSchema(CallToolRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); + } + + const { params } = validatedRequest.data; + + const result = await handler(request, ctx); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = parseSchema(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against CallToolResultSchema + const validationResult = parseSchema(CallToolResultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); + } + + return validationResult.data; + }; + } + + protected assertCapabilityForMethod(method: RequestMethod | string): void { + switch (method) { + case 'sampling/createMessage': { + if (!this._clientCapabilities?.sampling) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support sampling (required for ${method})`); + } + break; + } + + case 'elicitation/create': { + if (!this._clientCapabilities?.elicitation) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Client does not support elicitation (required for ${method})`); + } + break; + } + + case 'roots/list': { + if (!this._clientCapabilities?.roots) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support listing roots (required for ${method})` + ); + } + break; + } + + case 'ping': { + // No specific capability required for ping + break; + } + } + } + + protected assertNotificationCapability(method: NotificationMethod | string): void { + switch (method) { + case 'notifications/message': { + if (!this._capabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + } + + case 'notifications/resources/updated': + case 'notifications/resources/list_changed': { + if (!this._capabilities.resources) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying about resources (required for ${method})` + ); + } + break; + } + + case 'notifications/tools/list_changed': { + if (!this._capabilities.tools) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of tool list changes (required for ${method})` + ); + } + break; + } + + case 'notifications/prompts/list_changed': { + if (!this._capabilities.prompts) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Server does not support notifying of prompt list changes (required for ${method})` + ); + } + break; + } + + case 'notifications/elicitation/complete': { + if (!this._clientCapabilities?.elicitation?.url) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + `Client does not support URL elicitation (required for ${method})` + ); + } + break; + } + + case 'notifications/cancelled': { + // Cancellation notifications are always allowed + break; + } + + case 'notifications/progress': { + // Progress notifications are always allowed + break; + } + } + } + + protected assertRequestHandlerCapability(method: string): void { + switch (method) { + case 'completion/complete': { + if (!this._capabilities.completions) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support completions (required for ${method})`); + } + break; + } + + case 'logging/setLevel': { + if (!this._capabilities.logging) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support logging (required for ${method})`); + } + break; + } + + case 'prompts/get': + case 'prompts/list': { + if (!this._capabilities.prompts) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support prompts (required for ${method})`); + } + break; + } + + case 'resources/list': + case 'resources/templates/list': + case 'resources/read': { + if (!this._capabilities.resources) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support resources (required for ${method})`); + } + break; + } + + case 'tools/call': + case 'tools/list': { + if (!this._capabilities.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, `Server does not support tools (required for ${method})`); + } + break; + } + + case 'ping': + case 'initialize': { + // No specific capability required for these methods + break; + } + } + } + + protected assertTaskCapability(method: string): void { + assertClientRequestTaskCapability(this._clientCapabilities?.tasks?.requests, method, 'Client'); + } + + protected assertTaskHandlerCapability(method: string): void { + assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server'); + } + + private async _oninitialize(request: InitializeRequest): Promise { + const requestedVersion = request.params.protocolVersion; + + this._clientCapabilities = request.params.capabilities; + this._clientVersion = request.params.clientInfo; + + const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + ? requestedVersion + : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + + this.transport?.setProtocolVersion?.(protocolVersion); + + return { + protocolVersion, + capabilities: this.getCapabilities(), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + + /** + * After initialization has completed, this will be populated with the client's reported capabilities. + */ + getClientCapabilities(): ClientCapabilities | undefined { + return this._clientCapabilities; + } + + /** + * After initialization has completed, this will be populated with information about the client's name and version. + */ + getClientVersion(): Implementation | undefined { + return this._clientVersion; + } + + /** + * Returns the current server capabilities. + */ + public getCapabilities(): ServerCapabilities { + return this._capabilities; + } + + async ping() { + return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema); + } + + /** + * Request LLM sampling from the client (without tools). + * Returns single content block for backwards compatibility. + */ + async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client with tool support. + * Returns content that may be a single block or array (for parallel tool calls). + */ + async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client. + * When tools may or may not be present, returns the union type. + */ + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise; + + // Implementation + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { + // Capability check - only required when tools/toolChoice are provided + if ((params.tools || params.toolChoice) && !this._clientCapabilities?.sampling?.tools) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support sampling tools capability.'); + } + + // Message structure validation - always validate tool_use/tool_result pairs. + // These may appear even without tools/toolChoice in the current request when + // a previous sampling request returned tool_use and this is a follow-up with results. + if (params.messages.length > 0) { + const lastMessage = params.messages.at(-1)!; + const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; + const hasToolResults = lastContent.some(c => c.type === 'tool_result'); + + const previousMessage = params.messages.length > 1 ? params.messages.at(-2) : undefined; + const previousContent = previousMessage + ? Array.isArray(previousMessage.content) + ? previousMessage.content + : [previousMessage.content] + : []; + const hasPreviousToolUse = previousContent.some(c => c.type === 'tool_use'); + + if (hasToolResults) { + if (lastContent.some(c => c.type !== 'tool_result')) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'The last message must contain only tool_result content if any is present' + ); + } + if (!hasPreviousToolUse) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'tool_result blocks are not matching any tool_use from the previous message' + ); + } + } + if (hasPreviousToolUse) { + const toolUseIds = new Set(previousContent.filter(c => c.type === 'tool_use').map(c => (c as ToolUseContent).id)); + const toolResultIds = new Set( + lastContent.filter(c => c.type === 'tool_result').map(c => (c as ToolResultContent).toolUseId) + ); + if (toolUseIds.size !== toolResultIds.size || ![...toolUseIds].every(id => toolResultIds.has(id))) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + 'ids of tool_result blocks and tool_use blocks from previous message do not match' + ); + } + } + } + + // Use different schemas based on whether tools are provided + if (params.tools) { + return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + } + return this._requestWithSchema({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); + } + + /** + * Creates an elicitation request for the given parameters. + * For backwards compatibility, `mode` may be omitted for form requests and will default to `"form"`. + * @param params The parameters for the elicitation request. + * @param options Optional request options. + * @returns The result of the elicitation request. + */ + async elicitInput(params: ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions): Promise { + const mode = (params.mode ?? 'form') as 'form' | 'url'; + + switch (mode) { + case 'url': { + if (!this._clientCapabilities?.elicitation?.url) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support url elicitation.'); + } + + const urlParams = params as ElicitRequestURLParams; + return this._requestWithSchema({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + } + case 'form': { + if (!this._clientCapabilities?.elicitation?.form) { + throw new SdkError(SdkErrorCode.CapabilityNotSupported, 'Client does not support form elicitation.'); + } + + const formParams: ElicitRequestFormParams = + params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; + + const result = await this._requestWithSchema( + { method: 'elicitation/create', params: formParams }, + ElicitResultSchema, + options + ); + + if (result.action === 'accept' && result.content && formParams.requestedSchema) { + try { + const validator = this._jsonSchemaValidator.getValidator(formParams.requestedSchema as JsonSchemaType); + const validationResult = validator(result.content); + + if (!validationResult.valid) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof ProtocolError) { + throw error; + } + throw new ProtocolError( + ProtocolErrorCode.InternalError, + `Error validating elicitation response: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + return result; + } + } + } + + /** + * Creates a reusable callback that, when invoked, will send a `notifications/elicitation/complete` + * notification for the specified elicitation ID. + * + * @param elicitationId The ID of the elicitation to mark as complete. + * @param options Optional notification options. Useful when the completion notification should be related to a prior request. + * @returns A function that emits the completion notification when awaited. + */ + createElicitationCompletionNotifier(elicitationId: string, options?: NotificationOptions): () => Promise { + if (!this._clientCapabilities?.elicitation?.url) { + throw new SdkError( + SdkErrorCode.CapabilityNotSupported, + 'Client does not support URL elicitation (required for notifications/elicitation/complete)' + ); + } + + return () => + this.notification( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId + } + }, + options + ); + } + + async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { + return this._requestWithSchema({ method: 'roots/list', params }, ListRootsResultSchema, options); + } + + /** + * Sends a logging message to the client, if connected. + * Note: You only need to send the parameters object, not the entire JSON-RPC message. + * @see {@linkcode LoggingMessageNotification} + * @param params + * @param sessionId Optional for stateless transports and backward compatibility. + */ + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { + if (this._capabilities.logging && !this.isMessageIgnored(params.level, sessionId)) { + return this.notification({ method: 'notifications/message', params }); + } + } + + async sendResourceUpdated(params: ResourceUpdatedNotification['params']) { + return this.notification({ + method: 'notifications/resources/updated', + params + }); + } + + async sendResourceListChanged() { + return this.notification({ + method: 'notifications/resources/list_changed' + }); + } + + async sendToolListChanged() { + return this.notification({ method: 'notifications/tools/list_changed' }); + } + + async sendPromptListChanged() { + return this.notification({ method: 'notifications/prompts/list_changed' }); + } +} diff --git a/packages/server/src/server/stdio.examples.ts b/packages/server/src/server/stdio.examples.ts new file mode 100644 index 0000000..de4603e --- /dev/null +++ b/packages/server/src/server/stdio.examples.ts @@ -0,0 +1,22 @@ +/** + * Type-checked examples for `stdio.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { McpServer } from './mcp.js'; +import { StdioServerTransport } from './stdio.js'; + +/** + * Example: Basic stdio transport usage. + */ +async function StdioServerTransport_basicUsage() { + //#region StdioServerTransport_basicUsage + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(); + await server.connect(transport); + //#endregion StdioServerTransport_basicUsage +} diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts new file mode 100644 index 0000000..ac2dd3f --- /dev/null +++ b/packages/server/src/server/stdio.ts @@ -0,0 +1,138 @@ +import type { Readable, Writable } from 'node:stream'; + +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import { process } from '@modelcontextprotocol/server/_shims'; + +/** + * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. + * + * This transport is only available in Node.js environments. + * + * @example + * ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new StdioServerTransport(); + * await server.connect(transport); + * ``` + */ +export class StdioServerTransport implements Transport { + private _readBuffer: ReadBuffer = new ReadBuffer(); + private _started = false; + private _closed = false; + + constructor( + private _stdin: Readable = process.stdin, + private _stdout: Writable = process.stdout + ) {} + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + // Arrow functions to bind `this` properly, while maintaining function identity. + _ondata = (chunk: Buffer) => { + this._readBuffer.append(chunk); + this.processReadBuffer(); + }; + _onerror = (error: Error) => { + this.onerror?.(error); + }; + _onstdouterror = (error: Error) => { + this.onerror?.(error); + this.close().catch(() => { + // Ignore errors during close — we're already in an error path + }); + }; + + /** + * Starts listening for messages on `stdin`. + */ + async start(): Promise { + if (this._started) { + throw new Error( + 'StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.' + ); + } + + this._started = true; + this._stdin.on('data', this._ondata); + this._stdin.on('error', this._onerror); + this._stdout.on('error', this._onstdouterror); + } + + private processReadBuffer() { + while (true) { + try { + const message = this._readBuffer.readMessage(); + if (message === null) { + break; + } + + this.onmessage?.(message); + } catch (error) { + this.onerror?.(error as Error); + } + } + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + + // Remove our event listeners first + this._stdin.off('data', this._ondata); + this._stdin.off('error', this._onerror); + this._stdout.off('error', this._onstdouterror); + + // Check if we were the only data listener + const remainingDataListeners = this._stdin.listenerCount('data'); + if (remainingDataListeners === 0) { + // Only pause stdin if we were the only listener + // This prevents interfering with other parts of the application that might be using stdin + this._stdin.pause(); + } + + // Clear the buffer and notify closure + this._readBuffer.clear(); + this.onclose?.(); + } + + send(message: JSONRPCMessage): Promise { + if (this._closed) { + return Promise.reject(new Error('StdioServerTransport is closed')); + } + return new Promise((resolve, reject) => { + const json = serializeMessage(message); + + let settled = false; + const onError = (error: Error) => { + if (settled) return; + settled = true; + this._stdout.off('error', onError); + this._stdout.off('drain', onDrain); + reject(error); + }; + const onDrain = () => { + if (settled) return; + settled = true; + this._stdout.off('error', onError); + this._stdout.off('drain', onDrain); + resolve(); + }; + + this._stdout.once('error', onError); + + if (this._stdout.write(json)) { + if (settled) return; + settled = true; + this._stdout.off('error', onError); + resolve(); + } else if (!settled) { + this._stdout.once('drain', onDrain); + } + }); + } +} diff --git a/packages/server/src/server/streamableHttp.examples.ts b/packages/server/src/server/streamableHttp.examples.ts new file mode 100644 index 0000000..a805c1d --- /dev/null +++ b/packages/server/src/server/streamableHttp.examples.ts @@ -0,0 +1,66 @@ +/** + * Type-checked examples for `streamableHttp.ts`. + * + * These examples are synced into JSDoc comments via the sync-snippets script. + * Each function's region markers define the code snippet that appears in the docs. + * + * @module + */ + +import { McpServer } from './mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from './streamableHttp.js'; + +/** + * Example: Stateful Streamable HTTP transport (Web Standard). + */ +async function WebStandardStreamableHTTPServerTransport_stateful() { + //#region WebStandardStreamableHTTPServerTransport_stateful + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID() + }); + + await server.connect(transport); + //#endregion WebStandardStreamableHTTPServerTransport_stateful +} + +/** + * Example: Stateless Streamable HTTP transport (Web Standard). + */ +async function WebStandardStreamableHTTPServerTransport_stateless() { + //#region WebStandardStreamableHTTPServerTransport_stateless + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + //#endregion WebStandardStreamableHTTPServerTransport_stateless + return transport; +} + +// Stubs for framework-specific examples +declare const app: { all(path: string, handler: (c: { req: { raw: Request } }) => Promise): void }; + +/** + * Example: Using with Hono.js. + */ +function WebStandardStreamableHTTPServerTransport_hono(transport: WebStandardStreamableHTTPServerTransport) { + //#region WebStandardStreamableHTTPServerTransport_hono + app.all('/mcp', async c => { + return transport.handleRequest(c.req.raw); + }); + //#endregion WebStandardStreamableHTTPServerTransport_hono +} + +/** + * Example: Using with Cloudflare Workers. + */ +function WebStandardStreamableHTTPServerTransport_workers(transport: WebStandardStreamableHTTPServerTransport) { + //#region WebStandardStreamableHTTPServerTransport_workers + const worker = { + async fetch(request: Request): Promise { + return transport.handleRequest(request); + } + }; + //#endregion WebStandardStreamableHTTPServerTransport_workers + return worker; +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts new file mode 100644 index 0000000..fd3563a --- /dev/null +++ b/packages/server/src/server/streamableHttp.ts @@ -0,0 +1,1038 @@ +/** + * Web Standards Streamable HTTP Server Transport + * + * This is the core transport implementation using Web Standard APIs (`Request`, `Response`, `ReadableStream`). + * It can run on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc. + * + * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. + */ + +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + isInitializeRequest, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + JSONRPCMessageSchema, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; + +export type StreamId = string; +export type EventId = string; + +/** + * Interface for resumability support via event storage + */ +export interface EventStore { + /** + * Stores an event for later retrieval + * @param streamId ID of the stream the event belongs to + * @param message The JSON-RPC message to store + * @returns The generated event ID for the stored event + */ + storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; + + /** + * Get the stream ID associated with a given event ID. + * @param eventId The event ID to look up + * @returns The stream ID, or `undefined` if not found + * + * Optional: If not provided, the SDK will use the `streamId` returned by + * {@linkcode replayEventsAfter} for stream mapping. + */ + getStreamIdForEventId?(eventId: EventId): Promise; + + replayEventsAfter( + lastEventId: EventId, + { + send + }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + } + ): Promise; +} + +/** + * Internal stream mapping for managing SSE connections + */ +interface StreamMapping { + /** Stream controller for pushing SSE data - only used with `ReadableStream` approach */ + controller?: ReadableStreamDefaultController; + /** Text encoder for SSE formatting */ + encoder?: InstanceType; + /** Promise resolver for JSON response mode */ + resolveJson?: (response: Response) => void; + /** Cleanup function to close stream and remove mapping */ + cleanup: () => void; +} + +/** + * Configuration options for {@linkcode WebStandardStreamableHTTPServerTransport} + */ +export interface WebStandardStreamableHTTPServerTransportOptions { + /** + * Function that generates a session ID for the transport. + * The session ID SHOULD be globally unique and cryptographically secure (e.g., a securely generated UUID, a JWT, or a cryptographic hash) + * + * If not provided, session management is disabled (stateless mode). + */ + sessionIdGenerator?: (() => string) | undefined; + + /** + * A callback for session initialization events + * This is called when the server initializes a new session. + * Useful in cases when you need to register multiple mcp sessions + * and need to keep track of them. + * @param sessionId The generated session ID + */ + onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; + + /** + * A callback for session close events + * This is called when the server closes a session due to a `DELETE` request. + * Useful in cases when you need to clean up resources associated with the session. + * Note that this is different from the transport closing, if you are handling + * HTTP requests from multiple nodes you might want to close each + * {@linkcode WebStandardStreamableHTTPServerTransport} after a request is completed while still keeping the + * session open/running. + * @param sessionId The session ID that was closed + */ + onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + + /** + * If `true`, the server will return JSON responses instead of starting an SSE stream. + * This can be useful for simple request/response scenarios without streaming. + * Default is `false` (SSE streams are preferred). + */ + enableJsonResponse?: boolean; + + /** + * Event store for resumability support + * If provided, resumability will be enabled, allowing clients to reconnect and resume messages + */ + eventStore?: EventStore; + + /** + * List of allowed `Host` header values for DNS rebinding protection. + * If not specified, host validation is disabled. + * @deprecated Use external middleware for host validation instead. + */ + allowedHosts?: string[]; + + /** + * List of allowed `Origin` header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + * @deprecated Use external middleware for origin validation instead. + */ + allowedOrigins?: string[]; + + /** + * Enable DNS rebinding protection (requires `allowedHosts` and/or `allowedOrigins` to be configured). + * Default is `false` for backwards compatibility. + * @deprecated Use external middleware for DNS rebinding protection instead. + */ + enableDnsRebindingProtection?: boolean; + + /** + * Retry interval in milliseconds to suggest to clients in SSE `retry` field. + * When set, the server will send a `retry` field in SSE priming events to control + * client reconnection timing for polling behavior. + */ + retryInterval?: number; + + /** + * List of protocol versions that this transport will accept. + * Used to validate the `mcp-protocol-version` header in incoming requests. + * + * Note: When using {@linkcode server/server.Server.connect | Server.connect()}, the server automatically passes its + * `supportedProtocolVersions` to the transport, so you typically don't need + * to set this option directly. + * + * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} + */ + supportedProtocolVersions?: string[]; +} + +/** + * Options for handling a request + */ +export interface HandleRequestOptions { + /** + * Pre-parsed request body. If provided, the transport will use this instead of parsing `req.json()`. + * Useful when using body-parser middleware that has already parsed the body. + */ + parsedBody?: unknown; + + /** + * Authentication info from middleware. If provided, will be passed to message handlers. + */ + authInfo?: AuthInfo; +} + +/** + * Server transport for Web Standards Streamable HTTP: this implements the MCP Streamable HTTP transport specification + * using Web Standard APIs (`Request`, `Response`, `ReadableStream`). + * + * This transport works on any runtime that supports Web Standards: Node.js 18+, Cloudflare Workers, Deno, Bun, etc. + * + * In stateful mode: + * - Session ID is generated and included in response headers + * - Session ID is always included in initialization responses + * - Requests with invalid session IDs are rejected with `404 Not Found` + * - Non-initialization requests without a session ID are rejected with `400 Bad Request` + * - State is maintained in-memory (connections, message history) + * + * In stateless mode: + * - No Session ID is included in any responses + * - No session validation is performed + * + * @example Stateful setup + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateful" + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * + * const transport = new WebStandardStreamableHTTPServerTransport({ + * sessionIdGenerator: () => crypto.randomUUID() + * }); + * + * await server.connect(transport); + * ``` + * + * @example Stateless setup + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_stateless" + * const transport = new WebStandardStreamableHTTPServerTransport({ + * sessionIdGenerator: undefined + * }); + * ``` + * + * @example Hono.js + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_hono" + * app.all('/mcp', async c => { + * return transport.handleRequest(c.req.raw); + * }); + * ``` + * + * @example Cloudflare Workers + * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_workers" + * const worker = { + * async fetch(request: Request): Promise { + * return transport.handleRequest(request); + * } + * }; + * ``` + */ +export class WebStandardStreamableHTTPServerTransport implements Transport { + // when sessionId is not set (undefined), it means the transport is in stateless mode + private sessionIdGenerator: (() => string) | undefined; + private _started: boolean = false; + private _closed: boolean = false; + private _streamMapping: Map = new Map(); + private _requestToStreamMapping: Map = new Map(); + private _requestResponseMap: Map = new Map(); + private _initialized: boolean = false; + private _enableJsonResponse: boolean = false; + private _standaloneSseStreamId: string = '_GET_stream'; + private _eventStore?: EventStore; + private _onsessioninitialized?: ((sessionId: string) => void | Promise) | undefined; + private _onsessionclosed?: ((sessionId: string) => void | Promise) | undefined; + private _allowedHosts?: string[]; + private _allowedOrigins?: string[]; + private _enableDnsRebindingProtection: boolean; + private _retryInterval?: number; + private _supportedProtocolVersions: string[]; + + sessionId?: string; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + + constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { + this.sessionIdGenerator = options.sessionIdGenerator; + this._enableJsonResponse = options.enableJsonResponse ?? false; + this._eventStore = options.eventStore; + this._onsessioninitialized = options.onsessioninitialized; + this._onsessionclosed = options.onsessionclosed; + this._allowedHosts = options.allowedHosts; + this._allowedOrigins = options.allowedOrigins; + this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; + this._retryInterval = options.retryInterval; + this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; + } + + /** + * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op + * for the Streamable HTTP transport as connections are managed per-request. + */ + async start(): Promise { + if (this._started) { + throw new Error('Transport already started'); + } + this._started = true; + } + + /** + * Sets the supported protocol versions for header validation. + * Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._supportedProtocolVersions = versions; + } + + /** + * Helper to create a JSON error response + */ + private createJsonErrorResponse( + status: number, + code: number, + message: string, + options?: { headers?: Record; data?: string } + ): Response { + const error: { code: number; message: string; data?: string } = { code, message }; + if (options?.data !== undefined) { + error.data = options.data; + } + return Response.json( + { + jsonrpc: '2.0', + error, + id: null + }, + { + status, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + } + ); + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error response if validation fails, `undefined` if validation passes. + */ + private validateRequestHeaders(req: Request): Response | undefined { + // Skip validation if protection is not enabled + if (!this._enableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._allowedHosts && this._allowedHosts.length > 0) { + const hostHeader = req.headers.get('host'); + if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { + const error = `Invalid Host header: ${hostHeader}`; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(403, -32_000, error); + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._allowedOrigins && this._allowedOrigins.length > 0) { + const originHeader = req.headers.get('origin'); + if (originHeader && !this._allowedOrigins.includes(originHeader)) { + const error = `Invalid Origin header: ${originHeader}`; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(403, -32_000, error); + } + } + + return undefined; + } + + /** + * Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE` + * Returns a `Response` object (Web Standard) + */ + async handleRequest(req: Request, options?: HandleRequestOptions): Promise { + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + return validationError; + } + + switch (req.method) { + case 'POST': { + return this.handlePostRequest(req, options); + } + case 'GET': { + return this.handleGetRequest(req); + } + case 'DELETE': { + return this.handleDeleteRequest(req); + } + default: { + return this.handleUnsupportedRequest(); + } + } + } + + /** + * Writes a priming event to establish resumption capability. + * Only sends if `eventStore` is configured (opt-in for resumability) and + * the client's protocol version supports empty SSE data (>= `2025-11-25`). + */ + private async writePrimingEvent( + controller: ReadableStreamDefaultController, + encoder: InstanceType, + streamId: string, + protocolVersion: string + ): Promise { + if (!this._eventStore) { + return; + } + + // Priming events have empty data which older clients cannot handle. + // Only send priming events to clients with protocol version >= 2025-11-25 + // which includes the fix for handling empty SSE data. + if (protocolVersion < '2025-11-25') { + return; + } + + const primingEventId = await this._eventStore.storeEvent(streamId, {} as JSONRPCMessage); + + let primingEvent = `id: ${primingEventId}\ndata: \n\n`; + if (this._retryInterval !== undefined) { + primingEvent = `id: ${primingEventId}\nretry: ${this._retryInterval}\ndata: \n\n`; + } + controller.enqueue(encoder.encode(primingEvent)); + } + + /** + * Handles `GET` requests for SSE stream + */ + private async handleGetRequest(req: Request): Promise { + // The client MUST include an Accept header, listing text/event-stream as a supported content type. + const acceptHeader = req.headers.get('accept'); + if (!acceptHeader?.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept text/event-stream')); + return this.createJsonErrorResponse(406, -32_000, 'Not Acceptable: Client must accept text/event-stream'); + } + + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + const sessionError = this.validateSession(req); + if (sessionError) { + return sessionError; + } + const protocolError = this.validateProtocolVersion(req); + if (protocolError) { + return protocolError; + } + + // Handle resumability: check for Last-Event-ID header + if (this._eventStore) { + const lastEventId = req.headers.get('last-event-id'); + if (lastEventId) { + return this.replayEvents(lastEventId); + } + } + + // Check if there's already an active standalone SSE stream for this session + if (this._streamMapping.get(this._standaloneSseStreamId) !== undefined) { + // Only one GET SSE stream is allowed per session + this.onerror?.(new Error('Conflict: Only one SSE stream is allowed per session')); + return this.createJsonErrorResponse(409, -32_000, 'Conflict: Only one SSE stream is allowed per session'); + } + + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + // Create a ReadableStream with a controller we can use to push SSE events + const readable = new ReadableStream({ + start: controller => { + streamController = controller; + }, + cancel: () => { + // Stream was cancelled by client + this._streamMapping.delete(this._standaloneSseStreamId); + } + }); + + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }; + + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers['mcp-session-id'] = this.sessionId; + } + + // Store the stream mapping with the controller for pushing data + this._streamMapping.set(this._standaloneSseStreamId, { + controller: streamController!, + encoder, + cleanup: () => { + this._streamMapping.delete(this._standaloneSseStreamId); + try { + streamController!.close(); + } catch { + // Controller might already be closed + } + } + }); + + return new Response(readable, { headers }); + } + + /** + * Replays events that would have been sent after the specified event ID + * Only used when resumability is enabled + */ + private async replayEvents(lastEventId: string): Promise { + if (!this._eventStore) { + this.onerror?.(new Error('Event store not configured')); + return this.createJsonErrorResponse(400, -32_000, 'Event store not configured'); + } + + try { + // If getStreamIdForEventId is available, use it for conflict checking + let streamId: string | undefined; + if (this._eventStore.getStreamIdForEventId) { + streamId = await this._eventStore.getStreamIdForEventId(lastEventId); + + if (!streamId) { + this.onerror?.(new Error('Invalid event ID format')); + return this.createJsonErrorResponse(400, -32_000, 'Invalid event ID format'); + } + + // Check conflict with the SAME streamId we'll use for mapping + if (this._streamMapping.get(streamId) !== undefined) { + this.onerror?.(new Error('Conflict: Stream already has an active connection')); + return this.createJsonErrorResponse(409, -32_000, 'Conflict: Stream already has an active connection'); + } + } + + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }; + + if (this.sessionId !== undefined) { + headers['mcp-session-id'] = this.sessionId; + } + + // Create a ReadableStream with controller for SSE + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const readable = new ReadableStream({ + start: controller => { + streamController = controller; + }, + cancel: () => { + // Stream was cancelled by client + // Cleanup will be handled by the mapping + } + }); + + // Replay events - returns the streamId for backwards compatibility + const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { + send: async (eventId: string, message: JSONRPCMessage) => { + const success = this.writeSSEEvent(streamController!, encoder, message, eventId); + if (!success) { + try { + streamController!.close(); + } catch { + // Controller might already be closed + } + } + } + }); + + this._streamMapping.set(replayedStreamId, { + controller: streamController!, + encoder, + cleanup: () => { + this._streamMapping.delete(replayedStreamId); + try { + streamController!.close(); + } catch { + // Controller might already be closed + } + } + }); + + return new Response(readable, { headers }); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(500, -32_000, 'Error replaying events'); + } + } + + /** + * Writes an event to an SSE stream via controller with proper formatting + */ + private writeSSEEvent( + controller: ReadableStreamDefaultController, + encoder: InstanceType, + message: JSONRPCMessage, + eventId?: string + ): boolean { + try { + let eventData = `event: message\n`; + // Include event ID if provided - this is important for resumability + if (eventId) { + eventData += `id: ${eventId}\n`; + } + eventData += `data: ${JSON.stringify(message)}\n\n`; + controller.enqueue(encoder.encode(eventData)); + return true; + } catch (error) { + this.onerror?.(error as Error); + return false; + } + } + + /** + * Handles unsupported requests (`PUT`, `PATCH`, etc.) + */ + private handleUnsupportedRequest(): Response { + this.onerror?.(new Error('Method not allowed.')); + return Response.json( + { + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Method not allowed.' + }, + id: null + }, + { + status: 405, + headers: { + Allow: 'GET, POST, DELETE', + 'Content-Type': 'application/json' + } + } + ); + } + + /** + * Handles `POST` requests containing JSON-RPC messages + */ + private async handlePostRequest(req: Request, options?: HandleRequestOptions): Promise { + try { + // Validate the Accept header + const acceptHeader = req.headers.get('accept'); + // The client MUST include an Accept header, listing both application/json and text/event-stream as supported content types. + if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); + return this.createJsonErrorResponse( + 406, + -32_000, + 'Not Acceptable: Client must accept both application/json and text/event-stream' + ); + } + + const ct = req.headers.get('content-type'); + if (!ct || !ct.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); + return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + + const request = req; + + let rawMessage; + if (options?.parsedBody === undefined) { + try { + rawMessage = await req.json(); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); + } + } else { + rawMessage = options.parsedBody; + } + + let messages: JSONRPCMessage[]; + + // handle batch and single messages + try { + messages = Array.isArray(rawMessage) + ? rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)) + : [JSONRPCMessageSchema.parse(rawMessage)]; + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); + } + + // Check if this is an initialization request + // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ + const isInitializationRequest = messages.some(element => isInitializeRequest(element)); + if (isInitializationRequest) { + // If it's a server with session management and the session ID is already set we should reject the request + // to avoid re-initialization. + if (this._initialized && this.sessionId !== undefined) { + this.onerror?.(new Error('Invalid Request: Server already initialized')); + return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Server already initialized'); + } + if (messages.length > 1) { + this.onerror?.(new Error('Invalid Request: Only one initialization request is allowed')); + return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Only one initialization request is allowed'); + } + this.sessionId = this.sessionIdGenerator?.(); + this._initialized = true; + + // If we have a session ID and an onsessioninitialized handler, call it immediately + // This is needed in cases where the server needs to keep track of multiple sessions + if (this.sessionId && this._onsessioninitialized) { + await Promise.resolve(this._onsessioninitialized(this.sessionId)); + } + } + if (!isInitializationRequest) { + // If an Mcp-Session-Id is returned by the server during initialization, + // clients using the Streamable HTTP transport MUST include it + // in the Mcp-Session-Id header on all of their subsequent HTTP requests. + const sessionError = this.validateSession(req); + if (sessionError) { + return sessionError; + } + // Mcp-Protocol-Version header is required for all requests after initialization. + const protocolError = this.validateProtocolVersion(req); + if (protocolError) { + return protocolError; + } + } + + // check if it contains requests + const hasRequests = messages.some(element => isJSONRPCRequest(element)); + + if (!hasRequests) { + // if it only contains notifications or responses, return 202 + for (const message of messages) { + this.onmessage?.(message, { authInfo: options?.authInfo, request }); + } + return new Response(null, { status: 202 }); + } + + // The default behavior is to use SSE streaming + // but in some cases server will return JSON responses + const streamId = crypto.randomUUID(); + + // Extract protocol version for priming event decision. + // For initialize requests, get from request params. + // For other requests, get from header (already validated). + const initRequest = messages.find(m => isInitializeRequest(m)); + const clientProtocolVersion = initRequest + ? initRequest.params.protocolVersion + : (req.headers.get('mcp-protocol-version') ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + + if (this._enableJsonResponse) { + // For JSON response mode, return a Promise that resolves when all responses are ready + return new Promise(resolve => { + this._streamMapping.set(streamId, { + resolveJson: resolve, + cleanup: () => { + this._streamMapping.delete(streamId); + } + }); + + for (const message of messages) { + if (isJSONRPCRequest(message)) { + this._requestToStreamMapping.set(message.id, streamId); + } + } + + for (const message of messages) { + this.onmessage?.(message, { authInfo: options?.authInfo, request }); + } + }); + } + + // SSE streaming mode - use ReadableStream with controller for more reliable data pushing + const encoder = new TextEncoder(); + let streamController: ReadableStreamDefaultController; + + const readable = new ReadableStream({ + start: controller => { + streamController = controller; + }, + cancel: () => { + // Stream was cancelled by client + this._streamMapping.delete(streamId); + } + }); + + const headers: Record = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }; + + // After initialization, always include the session ID if we have one + if (this.sessionId !== undefined) { + headers['mcp-session-id'] = this.sessionId; + } + + // Store the response for this request to send messages back through this connection + // We need to track by request ID to maintain the connection + for (const message of messages) { + if (isJSONRPCRequest(message)) { + this._streamMapping.set(streamId, { + controller: streamController!, + encoder, + cleanup: () => { + this._streamMapping.delete(streamId); + try { + streamController!.close(); + } catch { + // Controller might already be closed + } + } + }); + this._requestToStreamMapping.set(message.id, streamId); + } + } + + // Write priming event if event store is configured (after mapping is set up) + await this.writePrimingEvent(streamController!, encoder, streamId, clientProtocolVersion); + + // handle each message + for (const message of messages) { + // Build closeSSEStream callback for requests when eventStore is configured + // AND client supports resumability (protocol version >= 2025-11-25). + // Old clients can't resume if the stream is closed early because they + // didn't receive a priming event with an event ID. + let closeSSEStream: (() => void) | undefined; + let closeStandaloneSSEStream: (() => void) | undefined; + if (isJSONRPCRequest(message) && this._eventStore && clientProtocolVersion >= '2025-11-25') { + closeSSEStream = () => { + this.closeSSEStream(message.id); + }; + closeStandaloneSSEStream = () => { + this.closeStandaloneSSEStream(); + }; + } + + this.onmessage?.(message, { authInfo: options?.authInfo, request, closeSSEStream, closeStandaloneSSEStream }); + } + // The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses + // This will be handled by the send() method when responses are ready + + return new Response(readable, { status: 200, headers }); + } catch (error) { + // return JSON-RPC formatted error + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error', { data: String(error) }); + } + } + + /** + * Handles `DELETE` requests to terminate sessions + */ + private async handleDeleteRequest(req: Request): Promise { + const sessionError = this.validateSession(req); + if (sessionError) { + return sessionError; + } + const protocolError = this.validateProtocolVersion(req); + if (protocolError) { + return protocolError; + } + + await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); + await this.close(); + return new Response(null, { status: 200 }); + } + + /** + * Validates session ID for non-initialization requests. + * Returns `Response` error if invalid, `undefined` otherwise + */ + private validateSession(req: Request): Response | undefined { + if (this.sessionIdGenerator === undefined) { + // If the sessionIdGenerator ID is not set, the session management is disabled + // and we don't need to validate the session ID + return undefined; + } + if (!this._initialized) { + // If the server has not been initialized yet, reject all requests + this.onerror?.(new Error('Bad Request: Server not initialized')); + return this.createJsonErrorResponse(400, -32_000, 'Bad Request: Server not initialized'); + } + + const sessionId = req.headers.get('mcp-session-id'); + + if (!sessionId) { + // Non-initialization requests without a session ID should return 400 Bad Request + this.onerror?.(new Error('Bad Request: Mcp-Session-Id header is required')); + return this.createJsonErrorResponse(400, -32_000, 'Bad Request: Mcp-Session-Id header is required'); + } + + if (sessionId !== this.sessionId) { + // Reject requests with invalid session ID with 404 Not Found + this.onerror?.(new Error('Session not found')); + return this.createJsonErrorResponse(404, -32_001, 'Session not found'); + } + + return undefined; + } + + /** + * Validates the `MCP-Protocol-Version` header on incoming requests. + * + * For initialization: Version negotiation handles unknown versions gracefully + * (server responds with its supported version). + * + * For subsequent requests with `MCP-Protocol-Version` header: + * - Accept if in supported list + * - 400 if unsupported + * + * For HTTP requests without the `MCP-Protocol-Version` header: + * - Accept and default to the version negotiated at initialization + */ + private validateProtocolVersion(req: Request): Response | undefined { + const protocolVersion = req.headers.get('mcp-protocol-version'); + + if (protocolVersion !== null && !this._supportedProtocolVersions.includes(protocolVersion)) { + const error = `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${this._supportedProtocolVersions.join(', ')})`; + this.onerror?.(new Error(error)); + return this.createJsonErrorResponse(400, -32_000, error); + } + return undefined; + } + + async close(): Promise { + if (this._closed) { + return; + } + this._closed = true; + + // Close all SSE connections + for (const { cleanup } of this._streamMapping.values()) { + cleanup(); + } + this._streamMapping.clear(); + + // Clear any pending responses + this._requestResponseMap.clear(); + this.onclose?.(); + } + + /** + * Close an SSE stream for a specific request, triggering client reconnection. + * Use this to implement polling behavior during long-running operations - + * client will reconnect after the retry interval specified in the priming event. + */ + closeSSEStream(requestId: RequestId): void { + const streamId = this._requestToStreamMapping.get(requestId); + if (!streamId) return; + + const stream = this._streamMapping.get(streamId); + if (stream) { + stream.cleanup(); + } + } + + /** + * Close the standalone `GET` SSE stream, triggering client reconnection. + * Use this to implement polling behavior for server-initiated notifications. + */ + closeStandaloneSSEStream(): void { + const stream = this._streamMapping.get(this._standaloneSseStreamId); + if (stream) { + stream.cleanup(); + } + } + + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { + let requestId = options?.relatedRequestId; + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // If the message is a response, use the request ID from the message + requestId = message.id; + } + + // Check if this message should be sent on the standalone SSE stream (no request ID) + // Ignore notifications from tools (which have relatedRequestId set) + // Those will be sent via dedicated response SSE streams + if (requestId === undefined) { + // For standalone SSE streams, we can only send requests and notifications + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + throw new Error('Cannot send a response on a standalone SSE stream unless resuming a previous client request'); + } + + // Generate and store event ID if event store is provided + // Store even if stream is disconnected so events can be replayed on reconnect + let eventId: string | undefined; + if (this._eventStore) { + // Stores the event and gets the generated event ID + eventId = await this._eventStore.storeEvent(this._standaloneSseStreamId, message); + } + + const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); + if (standaloneSse === undefined) { + // Stream is disconnected - event is stored for replay, nothing more to do + return; + } + + // Send the message to the standalone SSE stream + if (standaloneSse.controller && standaloneSse.encoder) { + this.writeSSEEvent(standaloneSse.controller, standaloneSse.encoder, message, eventId); + } + return; + } + + // Get the response for this request + const streamId = this._requestToStreamMapping.get(requestId); + if (!streamId) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + + const stream = this._streamMapping.get(streamId); + + if (!this._enableJsonResponse && stream?.controller && stream?.encoder) { + // For SSE responses, generate event ID if event store is provided + let eventId: string | undefined; + + if (this._eventStore) { + eventId = await this._eventStore.storeEvent(streamId, message); + } + // Write the event to the response stream + this.writeSSEEvent(stream.controller, stream.encoder, message, eventId); + } + + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._requestResponseMap.set(requestId, message); + const relatedIds = [...this._requestToStreamMapping.entries()].filter(([_, sid]) => sid === streamId).map(([id]) => id); + + // Check if we have responses for all requests using this connection + const allResponsesReady = relatedIds.every(id => this._requestResponseMap.has(id)); + + if (allResponsesReady) { + if (!stream) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + if (this._enableJsonResponse && stream.resolveJson) { + // All responses ready, send as JSON + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (this.sessionId !== undefined) { + headers['mcp-session-id'] = this.sessionId; + } + + const responses = relatedIds.map(id => this._requestResponseMap.get(id)!); + + if (responses.length === 1) { + stream.resolveJson(Response.json(responses[0], { status: 200, headers })); + } else { + stream.resolveJson(Response.json(responses, { status: 200, headers })); + } + } else { + // End the SSE stream + stream.cleanup(); + } + // Clean up + for (const id of relatedIds) { + this._requestResponseMap.delete(id); + this._requestToStreamMapping.delete(id); + } + } + } + } +} diff --git a/packages/server/src/shimsNode.ts b/packages/server/src/shimsNode.ts new file mode 100644 index 0000000..09283a4 --- /dev/null +++ b/packages/server/src/shimsNode.ts @@ -0,0 +1,7 @@ +/** + * Node.js runtime shims for server package + * + * This file is selected via package.json export conditions when running in Node.js. + */ +export { AjvJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core'; +export { default as process } from 'node:process'; diff --git a/packages/server/src/shimsWorkerd.ts b/packages/server/src/shimsWorkerd.ts new file mode 100644 index 0000000..dc23da8 --- /dev/null +++ b/packages/server/src/shimsWorkerd.ts @@ -0,0 +1,23 @@ +/** + * Cloudflare Workers runtime shims for server package + * + * This file is selected via package.json export conditions when running in workerd. + */ +export { CfWorkerJsonSchemaValidator as DefaultJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; + +/** + * Stub process object for non-Node.js environments. + * StdioServerTransport is not supported in Cloudflare Workers/browser environments. + */ +function notSupported(): never { + throw new Error('StdioServerTransport is not supported in this environment. Use StreamableHTTPServerTransport instead.'); +} + +export const process = { + get stdin(): never { + return notSupported(); + }, + get stdout(): never { + return notSupported(); + } +}; diff --git a/packages/server/src/stdio.ts b/packages/server/src/stdio.ts new file mode 100644 index 0000000..7865c9c --- /dev/null +++ b/packages/server/src/stdio.ts @@ -0,0 +1,8 @@ +// Subpath entry for the stdio server transport. +// +// Exported separately from the root entry to keep `StdioServerTransport` out of the default bundle +// surface — server stdio has only type-level Node imports, but matching the client's `./stdio` +// subpath gives consumers a consistent shape across packages. Import from +// `@modelcontextprotocol/server/stdio` only in process-stdio runtimes (Node.js, Bun, Deno). + +export { StdioServerTransport } from './server/stdio.js'; diff --git a/packages/server/src/validators/cfWorker.ts b/packages/server/src/validators/cfWorker.ts new file mode 100644 index 0000000..f804b76 --- /dev/null +++ b/packages/server/src/validators/cfWorker.ts @@ -0,0 +1,10 @@ +/** + * Cloudflare Workers JSON Schema validator, available as a sub-path export. + * + * @example + * ```ts + * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; + * ``` + */ +export type { CfWorkerSchemaDraft } from '@modelcontextprotocol/core/validators/cfWorker'; +export { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; diff --git a/packages/server/test/server/barrelClean.test.ts b/packages/server/test/server/barrelClean.test.ts new file mode 100644 index 0000000..e7f3e33 --- /dev/null +++ b/packages/server/test/server/barrelClean.test.ts @@ -0,0 +1,56 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeAll, describe, expect, test } from 'vitest'; + +const pkgDir = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const distDir = join(pkgDir, 'dist'); +const NODE_ONLY = /\b(child_process|cross-spawn|node:stream|node:child_process)\b/; + +function chunkImportsOf(entryPath: string): string[] { + const visited = new Set(); + const queue = [entryPath]; + while (queue.length > 0) { + const file = queue.shift()!; + if (visited.has(file)) continue; + visited.add(file); + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(/from\s+["']\.\/(.+?\.mjs)["']/g)) { + queue.push(join(dirname(file), m[1]!)); + } + } + visited.delete(entryPath); + return [...visited]; +} + +describe('@modelcontextprotocol/server root entry is browser-safe', () => { + beforeAll(() => { + if (!existsSync(join(distDir, 'index.mjs')) || !existsSync(join(distDir, 'stdio.mjs'))) { + execFileSync('pnpm', ['build'], { cwd: pkgDir, stdio: 'inherit' }); + } + }, 60_000); + + test('dist/index.mjs does not export StdioServerTransport and has no process-stdio runtime imports', () => { + const entry = readFileSync(join(distDir, 'index.mjs'), 'utf8'); + // Server stdio has only type-level node:stream imports (erased at compile time), so the + // meaningful regression check is that the symbol itself is absent from the root barrel. + expect(entry).not.toMatch(/\bexport\s*\{[^}]*\bStdioServerTransport\b/); + expect(entry).not.toMatch(NODE_ONLY); + }); + + test('chunks transitively imported by dist/index.mjs contain no process-stdio runtime imports', () => { + const entry = join(distDir, 'index.mjs'); + for (const chunk of chunkImportsOf(entry)) { + expect({ chunk, content: readFileSync(chunk, 'utf8') }).not.toEqual( + expect.objectContaining({ content: expect.stringMatching(NODE_ONLY) }) + ); + } + }); + + test('dist/stdio.mjs exists and exports StdioServerTransport', () => { + const stdio = readFileSync(join(distDir, 'stdio.mjs'), 'utf8'); + expect(stdio).toMatch(/\bStdioServerTransport\b/); + }); +}); diff --git a/packages/server/test/server/completable.test.ts b/packages/server/test/server/completable.test.ts new file mode 100644 index 0000000..9dfa7b4 --- /dev/null +++ b/packages/server/test/server/completable.test.ts @@ -0,0 +1,56 @@ +import * as z from 'zod/v4'; +import { describe, expect, it } from 'vitest'; + +import { completable, getCompleter } from '../../src/server/completable.js'; + +describe('completable with Zod v4', () => { + it('preserves types and values of underlying schema', () => { + const baseSchema = z.string(); + const schema = completable(baseSchema, () => []); + + expect(schema.parse('test')).toBe('test'); + expect(() => schema.parse(123)).toThrow(); + }); + + it('provides access to completion function', async () => { + const completions = ['foo', 'bar', 'baz']; + const schema = completable(z.string(), () => completions); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); + }); + + it('allows async completion functions', async () => { + const completions = ['foo', 'bar', 'baz']; + const schema = completable(z.string(), async () => completions); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); + }); + + it('passes current value to completion function', async () => { + const schema = completable(z.string(), value => [value + '!']); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('test')).toEqual(['test!']); + }); + + it('works with number schemas', async () => { + const schema = completable(z.number(), () => [1, 2, 3]); + + expect(schema.parse(1)).toBe(1); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!(0)).toEqual([1, 2, 3]); + }); + + it('preserves schema description', () => { + const desc = 'test description'; + const schema = completable(z.string().describe(desc), () => []); + + expect(schema.description).toBe(desc); + }); +}); diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts new file mode 100644 index 0000000..322b615 --- /dev/null +++ b/packages/server/test/server/mcp.compat.test.ts @@ -0,0 +1,129 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import { McpServer } from '../../src/index.js'; +import type { InferRawShape } from '../../src/server/mcp.js'; +import { completable } from '../../src/server/completable.js'; + +describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { + it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('a', { inputSchema: { x: z.number() } }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + server.registerTool('b', { inputSchema: { y: z.number() } }, async ({ y }) => ({ + content: [{ type: 'text' as const, text: String(y) }] + })); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(Object.keys(tools)).toEqual(['a', 'b']); + // raw shape was wrapped into a Standard Schema (z.object) + expect(isStandardSchema(tools['a']?.inputSchema)).toBe(true); + }); + + it('registerTool accepts a raw shape for outputSchema and auto-wraps it', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('out', { inputSchema: { n: z.number() }, outputSchema: { result: z.string() } }, async ({ n }) => ({ + content: [{ type: 'text' as const, text: String(n) }], + structuredContent: { result: String(n) } + })); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(isStandardSchema(tools['out']?.outputSchema)).toBe(true); + }); + + it('registerTool with z.object() inputSchema also works (passthrough, no auto-wrap)', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerTool('c', { inputSchema: z.object({ x: z.number() }) }, async ({ x }) => ({ + content: [{ type: 'text' as const, text: String(x) }] + })); + + const tools = (server as unknown as { _registeredTools: Record })._registeredTools; + expect(isStandardSchema(tools['c']?.inputSchema)).toBe(true); + }); + + it('registerPrompt accepts a raw shape for argsSchema', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerPrompt('p', { argsSchema: { topic: z.string() } }, async ({ topic }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: topic } }] + })); + + const prompts = (server as unknown as { _registeredPrompts: Record })._registeredPrompts; + expect(Object.keys(prompts)).toContain('p'); + expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); + }); + + it('registerPrompt raw shape accepts completable() fields (v1 pattern)', () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + server.registerPrompt( + 'p', + { + argsSchema: { + language: completable(z.string(), v => ['typescript', 'python'].filter(l => l.startsWith(v))) + } + }, + async ({ language }) => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: language } }] + }) + ); + + const prompts = (server as unknown as { _registeredPrompts: Record })._registeredPrompts; + expect(isStandardSchema(prompts['p']?.argsSchema)).toBe(true); + }); + + it('callback receives validated, typed args end-to-end via tools/call', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + let received: { x: number } | undefined; + server.registerTool('echo', { inputSchema: { x: z.number() } }, async args => { + received = args; + return { content: [{ type: 'text' as const, text: String(args.x) }] }; + }); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { x: 7 } } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + expect(received).toEqual({ x: 7 }); + const result = responses.find(r => 'id' in r && r.id === 2) as { result?: { content: Array<{ text: string }> } }; + expect(result.result?.content[0]?.text).toBe('7'); + + await server.close(); + }); +}); + +describe('InferRawShape', () => { + it('preserves optionality from .optional() as ?: keys', () => { + type S = InferRawShape<{ a: z.ZodString; b: z.ZodOptional }>; + expectTypeOf().toEqualTypeOf<{ a: string; b?: string | undefined }>(); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts new file mode 100644 index 0000000..fdb8214 --- /dev/null +++ b/packages/server/test/server/server.test.ts @@ -0,0 +1,42 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; +import { Server } from '../../src/server/server.js'; + +describe('Server', () => { + describe('_oninitialize', () => { + it('should propagate negotiated protocol version to transport', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const setProtocolVersion = vi.fn(); + (serverTransport as { setProtocolVersion?: (version: string) => void }).setProtocolVersion = setProtocolVersion; + + await server.connect(serverTransport); + + // Collect response from the server + const responsePromise = new Promise(resolve => { + clientTransport.onmessage = msg => resolve(msg); + }); + await clientTransport.start(); + + // Send initialize request directly + await clientTransport.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + } as JSONRPCMessage); + + await responsePromise; + + expect(setProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); + + await server.close(); + }); + }); +}); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts new file mode 100644 index 0000000..92671ca --- /dev/null +++ b/packages/server/test/server/stdio.test.ts @@ -0,0 +1,181 @@ +import { Readable, Writable } from 'node:stream'; + +import type { JSONRPCMessage } from '@modelcontextprotocol/core'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; + +import { StdioServerTransport } from '../../src/server/stdio.js'; + +let input: Readable; +let outputBuffer: ReadBuffer; +let output: Writable; + +beforeEach(() => { + input = new Readable({ + // We'll use input.push() instead. + read: () => {} + }); + + outputBuffer = new ReadBuffer(); + output = new Writable({ + write(chunk, _encoding, callback) { + outputBuffer.append(chunk); + callback(); + } + }); +}); + +test('should start then close cleanly', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + expect(didClose).toBeFalsy(); + await server.close(); + expect(didClose).toBeTruthy(); +}); + +test('should not read until started', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + let didRead = false; + const readMessage = new Promise(resolve => { + server.onmessage = message => { + didRead = true; + resolve(message); + }; + }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 1, + method: 'ping' + }; + input.push(serializeMessage(message)); + + expect(didRead).toBeFalsy(); + await server.start(); + expect(await readMessage).toEqual(message); +}); + +test('should read multiple messages', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + const messages: JSONRPCMessage[] = [ + { + jsonrpc: '2.0', + id: 1, + method: 'ping' + }, + { + jsonrpc: '2.0', + method: 'notifications/initialized' + } + ]; + + const readMessages: JSONRPCMessage[] = []; + const finished = new Promise(resolve => { + server.onmessage = message => { + readMessages.push(message); + if (JSON.stringify(message) === JSON.stringify(messages[1])) { + resolve(); + } + }; + }); + + input.push(serializeMessage(messages[0]!)); + input.push(serializeMessage(messages[1]!)); + + await server.start(); + await finished; + expect(readMessages).toEqual(messages); +}); + +test('should close and fire onerror when stdout errors', async () => { + const server = new StdioServerTransport(input, output); + + let receivedError: Error | undefined; + server.onerror = err => { + receivedError = err; + }; + let closeCount = 0; + server.onclose = () => { + closeCount++; + }; + + await server.start(); + output.emit('error', new Error('EPIPE')); + + expect(receivedError?.message).toBe('EPIPE'); + expect(closeCount).toBe(1); +}); + +test('should not fire onclose twice when close() is called after stdout error', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = () => {}; + + let closeCount = 0; + server.onclose = () => { + closeCount++; + }; + + await server.start(); + output.emit('error', new Error('EPIPE')); + await server.close(); + + expect(closeCount).toBe(1); +}); + +test('should reject send() when stdout errors before drain', async () => { + let completeWrite: ((error?: Error | null) => void) | undefined; + const slowOutput = new Writable({ + highWaterMark: 0, + write(_chunk, _encoding, callback) { + completeWrite = callback; + } + }); + + const server = new StdioServerTransport(input, slowOutput); + server.onerror = () => {}; + await server.start(); + + const sendPromise = server.send({ jsonrpc: '2.0', id: 1, method: 'ping' }); + completeWrite!(new Error('write EPIPE')); + + await expect(sendPromise).rejects.toThrow('write EPIPE'); + expect(slowOutput.listenerCount('drain')).toBe(0); + expect(slowOutput.listenerCount('error')).toBe(0); +}); + +test('should reject send() after transport is closed', async () => { + const server = new StdioServerTransport(input, output); + await server.start(); + await server.close(); + + await expect(server.send({ jsonrpc: '2.0', id: 1, method: 'ping' })).rejects.toThrow('closed'); +}); + +test('should fire onerror before onclose on stdout error', async () => { + const server = new StdioServerTransport(input, output); + + const events: string[] = []; + server.onerror = () => events.push('error'); + server.onclose = () => events.push('close'); + + await server.start(); + output.emit('error', new Error('EPIPE')); + + expect(events).toEqual(['error', 'close']); +}); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts new file mode 100644 index 0000000..7a23dd5 --- /dev/null +++ b/packages/server/test/server/streamableHttp.test.ts @@ -0,0 +1,996 @@ +import { randomUUID } from 'node:crypto'; + +import type { CallToolResult, JSONRPCErrorResponse, JSONRPCMessage } from '@modelcontextprotocol/core'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp.js'; +import type { EventId, EventStore, StreamId } from '../../src/server/streamableHttp.js'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-11-25', + capabilities: {} + }, + id: 'init-1' + } as JSONRPCMessage, + + initializeOldVersion: { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-06-18', + capabilities: {} + }, + id: 'init-1' + } as JSONRPCMessage, + + toolsList: { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'tools-1' + } as JSONRPCMessage +}; + +/** + * Helper to create a Web Standard Request + */ +function createRequest( + method: string, + body?: JSONRPCMessage | JSONRPCMessage[], + options?: { + sessionId?: string; + accept?: string; + contentType?: string; + extraHeaders?: Record; + } +): Request { + const headers: Record = {}; + + if (options?.accept) { + headers['Accept'] = options.accept; + } else if (method === 'POST') { + headers['Accept'] = 'application/json, text/event-stream'; + } else if (method === 'GET') { + headers['Accept'] = 'text/event-stream'; + } + + if (options?.contentType) { + headers['Content-Type'] = options.contentType; + } else if (body) { + headers['Content-Type'] = 'application/json'; + } + + if (options?.sessionId) { + headers['mcp-session-id'] = options.sessionId; + headers['mcp-protocol-version'] = '2025-11-25'; + } + + if (options?.extraHeaders) { + Object.assign(headers, options.extraHeaders); + } + + return new Request('http://localhost/mcp', { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }); +} + +/** + * Helper to extract text from SSE response + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to parse SSE data line + */ +function parseSSEData(text: string): unknown { + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + if (!dataLine) { + throw new Error('No data line found in SSE event'); + } + return JSON.parse(dataLine.slice(5).trim()); +} + +function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { + expect(data).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern) + }) + }); +} + +describe('Zod v4', () => { + describe('HTTPServerTransport', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let sessionId: string; + + beforeEach(async () => { + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'greet', + { + description: 'A simple greeting tool', + inputSchema: z.object({ name: z.string().describe('Name to greet') }) + }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + describe('Initialization', () => { + it('should initialize server and generate session ID', async () => { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('mcp-session-id')).toBeDefined(); + }); + + it('should reject second initialization request', async () => { + sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); + + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: 'second-init' + }; + + const request = createRequest('POST', secondInitMessage); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_600, /Server already initialized/); + }); + + it('should reject batch initialize request', async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client-2', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-2' + } + ]; + + const request = createRequest('POST', batchInitMessages); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_600, /Only one initialization request is allowed/); + }); + }); + + describe('POST Requests', () => { + it('should handle post requests via SSE response correctly', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventData = parseSSEData(text); + + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'greet', + description: 'A simple greeting tool' + }) + ]) + }), + id: 'tools-1' + }); + }); + + it('should call a tool and return the result', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; + + const request = createRequest('POST', toolCallMessage, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventData = parseSSEData(text); + + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + } + ] + }, + id: 'call-1' + }); + }); + + it('should reject requests without a valid session ID', async () => { + const request = createRequest('POST', TEST_MESSAGES.toolsList); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + const errorData = (await response.json()) as JSONRPCErrorResponse; + expectErrorResponse(errorData, -32_000, /Bad Request/); + expect(errorData.id).toBeNull(); + }); + + it('should reject invalid session ID', async () => { + await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId: 'invalid-session-id' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_001, /Session not found/); + }); + + it('should reject request with wrong Accept header', async () => { + const request = createRequest('POST', TEST_MESSAGES.initialize, { accept: 'application/json' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Not Acceptable/); + }); + + it('should reject request with wrong Content-Type header', async () => { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'text/plain' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Unsupported Media Type/); + }); + + it('should reject invalid JSON', async () => { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json' + }, + body: 'not valid json' + }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_700, /Parse error.*Invalid JSON/); + }); + + it('should accept notifications without session and return 202', async () => { + sessionId = await initializeServer(); + + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/initialized' + }; + + const request = createRequest('POST', notification, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(202); + }); + }); + + describe('GET Requests (SSE Stream)', () => { + it('should establish standalone SSE stream', async () => { + sessionId = await initializeServer(); + + const request = createRequest('GET', undefined, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('mcp-session-id')).toBe(sessionId); + }); + + it('should reject GET without Accept: text/event-stream', async () => { + sessionId = await initializeServer(); + + const request = createRequest('GET', undefined, { sessionId, accept: 'application/json' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Not Acceptable/); + }); + + it('should reject second standalone SSE stream', async () => { + sessionId = await initializeServer(); + + // First SSE stream + const request1 = createRequest('GET', undefined, { sessionId }); + const response1 = await transport.handleRequest(request1); + expect(response1.status).toBe(200); + + // Second SSE stream should be rejected + const request2 = createRequest('GET', undefined, { sessionId }); + const response2 = await transport.handleRequest(request2); + + expect(response2.status).toBe(409); + const errorData = await response2.json(); + expectErrorResponse(errorData, -32_000, /Conflict/); + }); + }); + + describe('DELETE Requests', () => { + it('should handle DELETE to close session', async () => { + sessionId = await initializeServer(); + + const request = createRequest('DELETE', undefined, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + + it('should reject DELETE without valid session', async () => { + await initializeServer(); + + const request = createRequest('DELETE', undefined, { sessionId: 'invalid-session' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(404); + }); + }); + + describe('Unsupported Methods', () => { + it('should reject PUT requests', async () => { + const request = new Request('http://localhost/mcp', { method: 'PUT' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(405); + expect(response.headers.get('Allow')).toBe('GET, POST, DELETE'); + }); + + it('should reject PATCH requests', async () => { + const request = new Request('http://localhost/mcp', { method: 'PATCH' }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(405); + }); + }); + }); + + describe('HTTPServerTransport - Stateless Mode', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + + beforeEach(async () => { + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'echo', + { description: 'Echo tool', inputSchema: z.object({ message: z.string() }) }, + async ({ message }): Promise => { + return { content: [{ type: 'text', text: message }] }; + } + ); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined + }); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + it('should work without session management', async () => { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get('mcp-session-id')).toBeNull(); + }); + + it('should not require session ID on subsequent requests', async () => { + // Initialize + const initRequest = createRequest('POST', TEST_MESSAGES.initialize); + await transport.handleRequest(initRequest); + + // Subsequent request without session ID should work + const request = createRequest('POST', TEST_MESSAGES.toolsList); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + }); + }); + + describe('HTTPServerTransport - JSON Response Mode', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let sessionId: string; + + beforeEach(async () => { + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'greet', + { description: 'Greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it('should return JSON response instead of SSE', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const data = await response.json(); + expect(data).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.any(Array) + }), + id: 'tools-1' + }); + }); + + it('should handle tool calls in JSON response mode', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'World' } + }, + id: 'call-1' + }; + + const request = createRequest('POST', toolCallMessage, { sessionId }); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const data = await response.json(); + expect(data).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [{ type: 'text', text: 'Hello, World!' }] + }, + id: 'call-1' + }); + }); + }); + + describe('HTTPServerTransport - Session Callbacks', () => { + it('should call onsessioninitialized callback', async () => { + const onInitialized = vi.fn(); + + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => 'test-session-123', + onsessioninitialized: onInitialized + }); + + await mcpServer.connect(transport); + + const request = createRequest('POST', TEST_MESSAGES.initialize); + await transport.handleRequest(request); + + expect(onInitialized).toHaveBeenCalledWith('test-session-123'); + + await transport.close(); + }); + + it('should call onsessionclosed callback on DELETE', async () => { + const onClosed = vi.fn(); + + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => 'test-session-456', + onsessionclosed: onClosed + }); + + await mcpServer.connect(transport); + + // Initialize first + const initRequest = createRequest('POST', TEST_MESSAGES.initialize); + await transport.handleRequest(initRequest); + + // Then delete + const deleteRequest = createRequest('DELETE', undefined, { sessionId: 'test-session-456' }); + await transport.handleRequest(deleteRequest); + + expect(onClosed).toHaveBeenCalledWith('test-session-456'); + }); + }); + + describe('HTTPServerTransport - Event Store (Resumability)', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let eventStore: EventStore; + let storedEvents: Map; + let sessionId: string; + + beforeEach(async () => { + storedEvents = new Map(); + + eventStore = { + async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + storedEvents.set(eventId, { streamId, message }); + return eventId; + }, + async getStreamIdForEventId(eventId: EventId): Promise { + const event = storedEvents.get(eventId); + return event?.streamId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } + ): Promise { + const lastEvent = storedEvents.get(lastEventId); + if (!lastEvent) { + throw new Error('Event not found'); + } + + // Replay events after lastEventId for the same stream + const streamId = lastEvent.streamId; + const entries = [...storedEvents.entries()]; + let foundLast = false; + + for (const [eventId, event] of entries) { + if (eventId === lastEventId) { + foundLast = true; + continue; + } + if (foundLast && event.streamId === streamId) { + await send(eventId, event.message); + } + } + + return streamId; + } + }; + + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.registerTool( + 'greet', + { description: 'Greeting tool', inputSchema: z.object({ name: z.string() }) }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it('should store events when event store is configured', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }); + await transport.handleRequest(request); + + // Events should have been stored (priming event + response) + expect(storedEvents.size).toBeGreaterThan(0); + }); + + it('should include event ID in SSE events', async () => { + sessionId = await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId }); + const response = await transport.handleRequest(request); + + const text = await readSSEEvent(response); + + // Should have id: field in the SSE event + expect(text).toContain('id:'); + }); + }); + + describe('HTTPServerTransport - Protocol Version Validation', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let sessionId: string; + + beforeEach(async () => { + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + return response.headers.get('mcp-session-id') as string; + } + + it('should reject unsupported protocol version in header', async () => { + sessionId = await initializeServer(); + + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'unsupported-version' + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32_000, /Unsupported protocol version/); + }); + }); + + describe('HTTPServerTransport - start() method', () => { + it('should throw error when started twice', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await transport.start(); + + await expect(transport.start()).rejects.toThrow('Transport already started'); + }); + }); + + describe('HTTPServerTransport - onerror callback', () => { + let transport: WebStandardStreamableHTTPServerTransport; + let mcpServer: McpServer; + let errors: Error[]; + + beforeEach(async () => { + errors = []; + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + transport.onerror = err => errors.push(err); + + await mcpServer.connect(transport); + }); + + afterEach(async () => { + await transport.close(); + }); + + async function initializeServer(): Promise { + const request = createRequest('POST', TEST_MESSAGES.initialize); + const response = await transport.handleRequest(request); + return response.headers.get('mcp-session-id') as string; + } + + it('should call onerror for invalid JSON', async () => { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json' + }, + body: 'not valid json' + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(SyntaxError); + }); + + it('should call onerror for invalid JSON-RPC message', async () => { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ not: 'valid jsonrpc' }) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.name).toBe('ZodError'); + }); + + it('should call onerror for missing Accept header on POST', async () => { + const request = createRequest('POST', TEST_MESSAGES.initialize, { accept: 'application/json' }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(406); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Not Acceptable'); + }); + + it('should call onerror for unsupported Content-Type', async () => { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'text/plain' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(415); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Unsupported Media Type'); + }); + + it('should call onerror for server not initialized', async () => { + const request = createRequest('POST', TEST_MESSAGES.toolsList); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Server not initialized'); + }); + + it('should call onerror for invalid session ID', async () => { + await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.toolsList, { sessionId: 'invalid-session-id' }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(404); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Session not found'); + }); + + it('should call onerror for re-initialization attempt', async () => { + await initializeServer(); + + const request = createRequest('POST', TEST_MESSAGES.initialize); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Server already initialized'); + }); + + it('should call onerror for GET without Accept header', async () => { + const sessionId = await initializeServer(); + + const request = createRequest('GET', undefined, { sessionId, accept: 'application/json' }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(406); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Not Acceptable'); + }); + + it('should call onerror for concurrent SSE streams', async () => { + const sessionId = await initializeServer(); + + const request1 = createRequest('GET', undefined, { sessionId }); + await transport.handleRequest(request1); + + const request2 = createRequest('GET', undefined, { sessionId }); + const response2 = await transport.handleRequest(request2); + + expect(response2.status).toBe(409); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Conflict'); + }); + + it('should call onerror for unsupported protocol version', async () => { + const sessionId = await initializeServer(); + + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'unsupported-version' + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + const response = await transport.handleRequest(request); + + expect(response.status).toBe(400); + expect(errors.length).toBeGreaterThan(0); + const error = errors[0]; + expect(error).toBeDefined(); + expect(error?.message).toContain('Unsupported protocol version'); + }); + }); + + describe('close() re-entrancy guard', () => { + it('should not recurse when onclose triggers a second close()', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + + let closeCallCount = 0; + transport.onclose = () => { + closeCallCount++; + // Simulate the Protocol layer calling close() again from within onclose — + // the re-entrancy guard should prevent infinite recursion / stack overflow. + void transport.close(); + }; + + // Should resolve without throwing RangeError: Maximum call stack size exceeded + await expect(transport.close()).resolves.toBeUndefined(); + expect(closeCallCount).toBe(1); + }); + + it('should clean up all streams exactly once even when close() is called concurrently', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + + const cleanupCalls: string[] = []; + + // Inject a fake stream entry to verify cleanup runs exactly once + // @ts-expect-error accessing private map for test purposes + transport._streamMapping.set('stream-1', { + cleanup: () => { + cleanupCalls.push('stream-1'); + } + }); + + // Fire two concurrent close() calls — only the first should proceed + await Promise.all([transport.close(), transport.close()]); + + expect(cleanupCalls).toEqual(['stream-1']); + }); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..7ab6d79 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/core/validators/cfWorker": [ + "./node_modules/@modelcontextprotocol/core/src/validators/cfWorkerProvider.ts" + ], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./src/shimsNode.ts"] + } + } +} diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts new file mode 100644 index 0000000..25a65f4 --- /dev/null +++ b/packages/server/tsdown.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + failOnWarn: 'ci-only', + // 1. Entry Points + // Directly matches package.json include/exclude globs + entry: ['src/index.ts', 'src/stdio.ts', 'src/shimsNode.ts', 'src/shimsWorkerd.ts', 'src/validators/cfWorker.ts'], + + // 2. Output Configuration + format: ['esm'], + outDir: 'dist', + clean: true, // Recommended: Cleans 'dist' before building + sourcemap: true, + + // 3. Platform & Target + target: 'esnext', + platform: 'node', + shims: true, // Polyfills common Node.js shims (__dirname, etc.) + + // 4. Type Definitions + // Bundles d.ts files into a single output + dts: { + resolver: 'tsc', + // override just for DTS generation: + compilerOptions: { + baseUrl: '.', + paths: { + '@modelcontextprotocol/core': ['../core/src/index.ts'], + '@modelcontextprotocol/core/public': ['../core/src/exports/public/index.ts'], + '@modelcontextprotocol/core/validators/cfWorker': ['../core/src/validators/cfWorkerProvider.ts'] + } + } + }, + // 5. Vendoring Strategy - Bundle the code for this specific package into the output, + // but treat all other dependencies as external (require/import). + noExternal: ['@modelcontextprotocol/core'], + + // 6. External packages - keep self-reference imports external for runtime resolution + external: ['@modelcontextprotocol/server/_shims'] +}); diff --git a/packages/server/typedoc.json b/packages/server/typedoc.json new file mode 100644 index 0000000..a9fd090 --- /dev/null +++ b/packages/server/typedoc.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src"], + "entryPointStrategy": "expand", + "exclude": ["**/*.test.ts", "**/__*__/**"], + "navigation": { + "includeGroups": true, + "includeCategories": true + } +} diff --git a/packages/server/vitest.config.js b/packages/server/vitest.config.js new file mode 100644 index 0000000..496fca3 --- /dev/null +++ b/packages/server/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d4fc799 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9175 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + devTools: + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.4 + '@types/content-type': + specifier: ^1.1.8 + version: 1.1.9 + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/cross-spawn': + specifier: ^6.0.6 + version: 6.0.6 + '@types/eventsource': + specifier: ^1.1.15 + version: 1.1.15 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/express-serve-static-core': + specifier: ^5.1.0 + version: 5.1.1 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20251217.1 + version: 7.0.0-dev.20260327.2 + '@valibot/to-json-schema': + specifier: ^1.5.0 + version: 1.6.0 + arktype: + specifier: ^2.1.29 + version: 2.2.0 + eslint: + specifier: ^9.39.2 + version: 9.39.4 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8 + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.24.0 + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + tsdown: + specifier: ^0.18.0 + version: 0.18.4 + tsx: + specifier: ^4.16.5 + version: 4.21.0 + typedoc: + specifier: ^0.28.14 + version: 0.28.18 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.1 + version: 8.57.2 + valibot: + specifier: ^1.2.0 + version: 1.3.1 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4 + vitest: + specifier: ^4.0.15 + version: 4.1.2 + wrangler: + specifier: ^4.14.4 + version: 4.78.0 + runtimeClientOnly: + cross-spawn: + specifier: ^7.0.5 + version: 7.0.6 + eventsource: + specifier: ^3.0.2 + version: 3.0.7 + eventsource-parser: + specifier: ^3.0.0 + version: 3.0.6 + jose: + specifier: ^6.1.3 + version: 6.2.2 + runtimeServerOnly: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.11 + cors: + specifier: ^2.8.5 + version: 2.8.6 + express: + specifier: ^5.2.1 + version: 5.2.1 + fastify: + specifier: ^5.2.0 + version: 5.8.4 + hono: + specifier: ^4.11.4 + version: 4.12.9 + runtimeShared: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + ajv: + specifier: ^8.17.1 + version: 8.18.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1 + json-schema-typed: + specifier: ^8.0.2 + version: 8.0.2 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.1 + zod: + specifier: ^4.2.0 + version: 4.3.6 + +overrides: + strip-ansi: 6.0.1 + +importers: + + .: + devDependencies: + '@cfworker/json-schema': + specifier: catalog:runtimeShared + version: 4.1.1 + '@changesets/changelog-github': + specifier: ^0.5.2 + version: 0.5.2(encoding@0.1.13) + '@changesets/cli': + specifier: ^2.29.8 + version: 2.30.0(@types/node@24.12.0) + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:packages/client + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:packages/server + '@types/content-type': + specifier: catalog:devTools + version: 1.1.9 + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/cross-spawn': + specifier: catalog:devTools + version: 6.0.6 + '@types/eventsource': + specifier: catalog:devTools + version: 1.1.15 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + '@types/express-serve-static-core': + specifier: catalog:devTools + version: 5.1.1 + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + '@types/supertest': + specifier: catalog:devTools + version: 6.0.3 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 + lefthook: + specifier: ^2.0.16 + version: 2.1.4 + prettier: + specifier: catalog:devTools + version: 3.6.2 + supertest: + specifier: catalog:devTools + version: 7.2.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tslib: + specifier: ^2.8.1 + version: 2.8.1 + tsx: + specifier: catalog:devTools + version: 4.21.0 + typedoc: + specifier: catalog:devTools + version: 0.28.18(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + + common/eslint-config: + dependencies: + typescript: + specifier: catalog:devTools + version: 5.9.3 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-import-resolver-typescript: + specifier: ^4.4.4 + version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + eslint-plugin-simple-import-sort: + specifier: ^12.1.1 + version: 12.1.1(eslint@9.39.4) + eslint-plugin-unicorn: + specifier: ^62.0.0 + version: 62.0.0(eslint@9.39.4) + prettier: + specifier: catalog:devTools + version: 3.6.2 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + + common/tsconfig: + dependencies: + typescript: + specifier: catalog:devTools + version: 5.9.3 + + common/vitest-config: + dependencies: + typescript: + specifier: catalog:devTools + version: 5.9.3 + devDependencies: + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + vite-tsconfig-paths: + specifier: catalog:devTools + version: 5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + examples/client: + dependencies: + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + open: + specifier: ^11.0.0 + version: 11.0.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/examples-shared': + specifier: workspace:^ + version: link:../shared + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + + examples/client-quickstart: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.74.0 + version: 0.74.0(zod@4.3.6) + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + + examples/server: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + '@modelcontextprotocol/examples-shared': + specifier: workspace:^ + version: link:../shared + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../../packages/middleware/express + '@modelcontextprotocol/hono': + specifier: workspace:^ + version: link:../../packages/middleware/hono + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + version: 2.2.0 + better-auth: + specifier: ^1.4.17 + version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + hono: + specifier: catalog:runtimeServerOnly + version: 4.12.9 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + + examples/server-quickstart: + dependencies: + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.12.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + + examples/shared: + dependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../../packages/middleware/express + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + better-auth: + specifier: ^1.4.17 + version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))) + better-sqlite3: + specifier: ^12.6.2 + version: 12.8.0 + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../../test/helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/client: + dependencies: + cross-spawn: + specifier: catalog:runtimeClientOnly + version: 7.0.6 + eventsource: + specifier: catalog:runtimeClientOnly + version: 3.0.7 + eventsource-parser: + specifier: catalog:runtimeClientOnly + version: 3.0.6 + jose: + specifier: catalog:runtimeClientOnly + version: 6.2.2 + pkce-challenge: + specifier: catalog:runtimeShared + version: 5.0.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@cfworker/json-schema': + specifier: catalog:runtimeShared + version: 4.1.1 + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../../test/helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/content-type': + specifier: catalog:devTools + version: 1.1.9 + '@types/cross-spawn': + specifier: catalog:devTools + version: 6.0.6 + '@types/eventsource': + specifier: catalog:devTools + version: 1.1.15 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/codemod: + dependencies: + commander: + specifier: ^13.0.0 + version: 13.1.0 + ts-morph: + specifier: ^28.0.0 + version: 28.0.0 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/core: + dependencies: + ajv: + specifier: catalog:runtimeShared + version: 8.18.0 + ajv-formats: + specifier: catalog:runtimeShared + version: 3.0.1(ajv@8.18.0) + json-schema-typed: + specifier: catalog:runtimeShared + version: 8.0.2 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@cfworker/json-schema': + specifier: catalog:runtimeShared + version: 4.1.1 + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/content-type': + specifier: catalog:devTools + version: 1.1.9 + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/cross-spawn': + specifier: catalog:devTools + version: 6.0.6 + '@types/eventsource': + specifier: catalog:devTools + version: 1.1.15 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + '@types/express-serve-static-core': + specifier: catalog:devTools + version: 5.1.1 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/middleware/express: + dependencies: + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: ^4.18.0 || ^5.0.0 + version: 5.2.1 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../../common/eslint-config + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../../common/vitest-config + '@types/cors': + specifier: catalog:devTools + version: 2.8.19 + '@types/express': + specifier: catalog:devTools + version: 5.0.6 + '@types/express-serve-static-core': + specifier: catalog:devTools + version: 5.1.1 + '@types/supertest': + specifier: catalog:devTools + version: 6.0.3 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + supertest: + specifier: catalog:devTools + version: 7.2.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/middleware/fastify: + dependencies: + fastify: + specifier: catalog:runtimeServerOnly + version: 5.8.4 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../../common/eslint-config + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/middleware/hono: + dependencies: + hono: + specifier: catalog:runtimeServerOnly + version: 4.12.9 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../../common/eslint-config + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/middleware/node: + dependencies: + '@hono/node-server': + specifier: catalog:runtimeServerOnly + version: 1.19.11(hono@4.12.9) + hono: + specifier: catalog:runtimeServerOnly + version: 4.12.9 + devDependencies: + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../../common/eslint-config + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../server + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../../../test/helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + packages/server: + dependencies: + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + devDependencies: + '@cfworker/json-schema': + specifier: catalog:runtimeShared + version: 4.1.1 + '@eslint/js': + specifier: catalog:devTools + version: 9.39.4 + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../../test/helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@types/eventsource': + specifier: catalog:devTools + version: 1.1.15 + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 + eslint: + specifier: catalog:devTools + version: 9.39.4 + eslint-config-prettier: + specifier: catalog:devTools + version: 10.1.8(eslint@9.39.4) + eslint-plugin-n: + specifier: catalog:devTools + version: 17.24.0(eslint@9.39.4)(typescript@5.9.3) + prettier: + specifier: catalog:devTools + version: 3.6.2 + supertest: + specifier: catalog:devTools + version: 7.2.2 + tsdown: + specifier: catalog:devTools + version: 0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3) + tsx: + specifier: catalog:devTools + version: 4.21.0 + typescript: + specifier: catalog:devTools + version: 5.9.3 + typescript-eslint: + specifier: catalog:devTools + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + + test/conformance: + devDependencies: + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + '@modelcontextprotocol/conformance': + specifier: 0.1.15 + version: 0.1.15(@cfworker/json-schema@4.1.1) + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + cors: + specifier: catalog:runtimeServerOnly + version: 2.8.6 + express: + specifier: catalog:runtimeServerOnly + version: 5.2.1 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + + test/helpers: + devDependencies: + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + + test/integration: + devDependencies: + '@cfworker/json-schema': + specifier: catalog:runtimeShared + version: 4.1.1 + '@modelcontextprotocol/client': + specifier: workspace:^ + version: link:../../packages/client + '@modelcontextprotocol/core': + specifier: workspace:^ + version: link:../../packages/core + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/express': + specifier: workspace:^ + version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:^ + version: link:../../packages/middleware/node + '@modelcontextprotocol/server': + specifier: workspace:^ + version: link:../../packages/server + '@modelcontextprotocol/test-helpers': + specifier: workspace:^ + version: link:../helpers + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@valibot/to-json-schema': + specifier: catalog:devTools + version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) + arktype: + specifier: catalog:devTools + version: 2.2.0 + supertest: + specifier: catalog:devTools + version: 7.2.2 + valibot: + specifier: catalog:devTools + version: 1.3.1(typescript@5.9.3) + vitest: + specifier: catalog:devTools + version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + wrangler: + specifier: catalog:devTools + version: 4.78.0 + zod: + specifier: catalog:runtimeShared + version: 4.3.6 + +packages: + + '@anthropic-ai/sdk@0.74.0': + resolution: {integrity: sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@better-auth/core@1.5.6': + resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==} + peerDependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.2 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + '@better-auth/drizzle-adapter@1.5.6': + resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + drizzle-orm: '>=0.41.0' + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.5.6': + resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + kysely: ^0.27.0 || ^0.28.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.5.6': + resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + + '@better-auth/mongo-adapter@1.5.6': + resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.5.6': + resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.5.6': + resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==} + peerDependencies: + '@better-auth/core': 1.5.6 + + '@better-auth/utils@0.3.1': + resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@changesets/apply-release-plan@7.1.0': + resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} + + '@changesets/assemble-release-plan@6.0.9': + resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/changelog-github@0.5.2': + resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==} + + '@changesets/cli@2.30.0': + resolution: {integrity: sha512-5D3Nk2JPqMI1wK25pEymeWRSlSMdo5QOGlyfrKg0AOufrUcjEE3RQgaCpHoBiM31CSNrtSgdJ0U6zL1rLDDfBA==} + hasBin: true + + '@changesets/config@3.1.3': + resolution: {integrity: sha512-vnXjcey8YgBn2L1OPWd3ORs0bGC4LoYcK/ubpgvzNVr53JXV5GiTVj7fWdMRsoKUH7hhhMAQnsJUqLr21EncNw==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-github-info@0.7.0': + resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==} + + '@changesets/get-release-plan@4.0.15': + resolution: {integrity: sha512-Q04ZaRPuEVZtA+auOYgFaVQQSA98dXiVe/yFaZfY7hoSmQICHGvP0TF4u3EDNHWmmCS4ekA/XSpKlSM2PyTS2g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@cloudflare/kv-asset-handler@0.4.2': + resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} + engines: {node: '>=18.0.0'} + + '@cloudflare/unenv-preset@2.16.0': + resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260317.1': + resolution: {integrity: sha512-8hjh3sPMwY8M/zedq3/sXoA2Q4BedlGufn3KOOleIG+5a4ReQKLlUah140D7J6zlKmYZAFMJ4tWC7hCuI/s79g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260317.1': + resolution: {integrity: sha512-M/MnNyvO5HMgoIdr3QHjdCj2T1ki9gt0vIUnxYxBu9ISXS/jgtMl6chUVPJ7zHYBn9MyYr8ByeN6frjYxj0MGg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260317.1': + resolution: {integrity: sha512-1ltuEjkRcS3fsVF7CxsKlWiRmzq2ZqMfqDN0qUOgbUwkpXsLVJsXmoblaLf5OP00ELlcgF0QsN0p2xPEua4Uug==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260317.1': + resolution: {integrity: sha512-3QrNnPF1xlaNwkHpasvRvAMidOvQs2NhXQmALJrEfpIJ/IDL2la8g499yXp3eqhG3hVMCB07XVY149GTs42Xtw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260317.1': + resolution: {integrity: sha512-MfZTz+7LfuIpMGTa3RLXHX8Z/pnycZLItn94WRdHr8LPVet+C5/1Nzei399w/jr3+kzT4pDKk26JF/tlI5elpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.9.1': + resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} + + '@emnapi/runtime@1.9.1': + resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + + '@emnapi/wasi-threads@1.2.0': + resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@modelcontextprotocol/conformance@0.1.15': + resolution: {integrity: sha512-B1eNYpv5kas9YFC40su7SWhKHR2DwYzJRpiX6dfWzDWMcDr71myKVj//PwyqUHC7oucs3EJqcqnwvSuUwvenoQ==} + hasBin: true + + '@modelcontextprotocol/sdk@1.28.0': + resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.8': + resolution: {integrity: sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@oxc-project/types@0.103.0': + resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} + + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-GoOVDy8bjw9z1K30Oo803nSzXJS/vWhFijFsW3kzvZCO8IZwFnNa6pGctmbbJstKl3Fv6UBwyjJQN6msejW0IQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-9c4FOhRGpl+PX7zBK5p17c5efpF9aSpTPgyigv57hXf5NjQUaJOOiejPLAtFiKNBIfm5Uu6yFkvLKzOafNvlTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.57': + resolution: {integrity: sha512-6RsB8Qy4LnGqNGJJC/8uWeLWGOvbRL/KG5aJ8XXpSEupg/KQtlBEiFaYU/Ma5Usj1s+bt3ItkqZYAI50kSplBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.57': + resolution: {integrity: sha512-uA9kG7+MYkHTbqwv67Tx+5GV5YcKd33HCJIi0311iYBd25yuwyIqvJfBdt1VVB8tdOlyTb9cPAgfCki8nhwTQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.57': + resolution: {integrity: sha512-3KkS0cHsllT2T+Te+VZMKHNw6FPQihYsQh+8J4jkzwgvAQpbsbXmrqhkw3YU/QGRrD8qgcOvBr6z5y6Jid+rmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.57': + resolution: {integrity: sha512-A3/wu1RgsHhqP3rVH2+sM81bpk+Qd2XaHTl8LtX5/1LNR7QVBFBCpAoiXwjTdGnI5cMdBVi7Z1pi52euW760Fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': + resolution: {integrity: sha512-d0kIVezTQtazpyWjiJIn5to8JlwfKITDqwsFv0Xc6s31N16CD2PC/Pl2OtKgS7n8WLOJbfqgIp5ixYzTAxCqMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': + resolution: {integrity: sha512-E199LPijo98yrLjPCmETx8EF43sZf9t3guSrLee/ej1rCCc3zDVTR4xFfN9BRAapGVl7/8hYqbbiQPTkv73kUg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': + resolution: {integrity: sha512-++EQDpk/UJ33kY/BNsh7A7/P1sr/jbMuQ8cE554ZIy+tCUWCivo9zfyjDUoiMdnxqX6HLJEqqGnbGQOvzm2OMQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': + resolution: {integrity: sha512-voDEBcNqxbUv/GeXKFtxXVWA+H45P/8Dec4Ii/SbyJyGvCqV1j+nNHfnFUIiRQ2Q40DwPe/djvgYBs9PpETiMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.57': + resolution: {integrity: sha512-bRhcF7NLlCnpkzLVlVhrDEd0KH22VbTPkPTbMjlYvqhSmarxNIq5vtlQS8qmV7LkPKHrNLWyJW/V/sOyFba26Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.57': + resolution: {integrity: sha512-rnDVGRks2FQ2hgJ2g15pHtfxqkGFGjJQUDWzYznEkE8Ra2+Vag9OffxdbJMZqBWXHVM0iS4dv8qSiEn7bO+n1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.57': + resolution: {integrity: sha512-OqIUyNid1M4xTj6VRXp/Lht/qIP8fo25QyAZlCP+p6D2ATCEhyW4ZIFLnC9zAGN/HMbXoCzvwfa8Jjg/8J4YEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.57': + resolution: {integrity: sha512-aQNelgx14tGA+n2tNSa9x6/jeoCL9fkDeCei7nOKnHx0fEFRRMu5ReiITo+zZD5TzWDGGRjbSYCs93IfRIyTuQ==} + + '@rolldown/pluginutils@1.0.0-rc.12': + resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.15': + resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@ts-morph/common@0.29.0': + resolution: {integrity: sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/content-type@1.1.9': + resolution: {integrity: sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/eventsource@1.1.15': + resolution: {integrity: sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/node@25.5.0': + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-lEcUWwu2DLY0NjoB3x7Fivz63zd57D1fD6PD8ByQqa0/xO8+EC5GHr5YniJlHSpF05cn2sgl7SPS92qVs0Xhlw==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-IvUv0dCaVNKbA9Oq/GEEhtBPF9PP3E5MfW4pla4NpDsZh+6/nL5p0WXvlocV0fe1rT9fqmCQfk3oH+XrN1I8Qw==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-k98Q20XAC8bM9L915GFIfjo/ii/sYIaEzIDH3l6MwhiJMDPucARQpfbReUdXl8N3TsdCNwk8xay0pNIK0DOkvg==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-7ATSft1YojnRp/VG70i97CxFe+umhTHYA/jJwQBPrjdopAFa5IvFDr4kNajjy1uypbz/x6pfvCnTdOd1/lM3FQ==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-XTe5M1sTwjlxHS1NaNci4vf2nR7bAEwPB70SbhbSTS9ka+75mTP7cv8jHbGhKXEPxDLURBPSyvionog4+5NXmA==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-1F86Pmf1QcHv0IENBJJ7nWWVSWjR1nn4jokuSbWiPkKGD57rkJWiHY7xtpt4ql8ZG4bIWxdHonLaepBfjIkaug==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-zMI8AIWRr98hOgTC5DCe3GD/MGHfusX/E/Dz64Xcf+re4EUVS/pXP5fAUb3dQr8ndi9mHbM0DEmNb4ctSxsxew==} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260327.2': + resolution: {integrity: sha512-npU/LrswTK7gawemSkI2BufIgNgoOHA1OwwIC5EUh++oWLDuWZSvSAcH6mfn28NOt5A196zrHQd3SK7f5XCVAw==} + hasBin: true + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@valibot/to-json-schema@1.6.0': + resolution: {integrity: sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A==} + peerDependencies: + valibot: ^1.3.0 + + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.2.0: + resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} + engines: {node: '>=6.0.0'} + hasBin: true + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + better-auth@1.5.6: + resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.2: + resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + better-sqlite3@12.8.0: + resolution: {integrity: sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + dataloader@1.4.0: + resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dotenv@8.6.0: + resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} + engines: {node: '>=10'} + + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.328: + resolution: {integrity: sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@4.4.4: + resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==} + engines: {node: ^16.17.0 || >=18.6.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + + eslint-plugin-unicorn@62.0.0: + resolution: {integrity: sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.3.0: + resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify@5.8.4: + resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.5.0: + resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + + hookable@6.1.0: + resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kysely@0.28.14: + resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} + engines: {node: '>=20.0.0'} + + lefthook-darwin-arm64@2.1.4: + resolution: {integrity: sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.1.4: + resolution: {integrity: sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.1.4: + resolution: {integrity: sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.1.4: + resolution: {integrity: sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.1.4: + resolution: {integrity: sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.1.4: + resolution: {integrity: sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.1.4: + resolution: {integrity: sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.1.4: + resolution: {integrity: sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.1.4: + resolution: {integrity: sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.1.4: + resolution: {integrity: sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw==} + cpu: [x64] + os: [win32] + + lefthook@2.1.4: + resolution: {integrity: sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w==} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + miniflare@4.20260317.3: + resolution: {integrity: sha512-tK78D3X4q30/SXqVwMhWrUfH+ffRou9dJLC+jkhNy5zh1I7i7T4JH6xihOvYxdCSBavJ5fQXaaxDJz6orh09BA==} + engines: {node: '>=18.0.0'} + hasBin: true + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanostores@1.2.0: + resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} + engines: {node: ^20.0.0 || >=22.0.0} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@3.89.0: + resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} + engines: {node: '>=10'} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rolldown-plugin-dts@0.20.0: + resolution: {integrity: sha512-cLAY1kN2ilTYMfZcFlGWbXnu6Nb+8uwUBsi+Mjbh4uIx7IN8uMOmJ7RxrrRgPsO4H7eSz3E+JwGoL1gyugiyUA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20250601.1' + rolldown: ^1.0.0-beta.57 + typescript: ^5.0.0 + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-beta.57: + resolution: {integrity: sha512-lMMxcNN71GMsSko8RyeTaFoATHkCh4IWU7pYF73ziMYjhHZWfVesC6GQ+iaJCvZmVjvgSks9Ks1aaqEkBd8udg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.0.0-rc.12: + resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.0: + resolution: {integrity: sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-morph@28.0.0: + resolution: {integrity: sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tsdown@0.18.4: + resolution: {integrity: sha512-J/tRS6hsZTkvqmt4+xdELUCkQYDuUCXgBv0fw3ImV09WPGbEKfsPD65E+WUjSu3E7Z6tji9XZ1iWs8rbGqB/ZA==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-lightningcss: ^0.4.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-lightningcss: + optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typedoc@0.28.18: + resolution: {integrity: sha512-NTWTUOFRQ9+SGKKTuWKUioUkjxNwtS3JDRPVKZAXGHZy2wCA8bdv2iJiyeePn0xkmK+TCCqZFT0X7+2+FLjngA==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + unrun@0.2.34: + resolution: {integrity: sha512-LyaghRBR++r7svhDK6tnDz2XaYHWdneBOA0jbS8wnRsHerI9MFljX4fIiTgbbNbEVzZ0C9P1OjWLLe1OqoaaEw==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workerd@1.20260317.1: + resolution: {integrity: sha512-ZuEq1OdrJBS+NV+L5HMYPCzVn49a2O60slQiiLpG44jqtlOo+S167fWC76kEXteXLLLydeuRrluRel7WdOUa4g==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.78.0: + resolution: {integrity: sha512-He/vUhk4ih0D0eFmtNnlbT6Od8j+BEokaSR+oYjbVsH0SWIrIch+eHqfLRSBjBQaOoh6HCNxcafcIkBm2u0Hag==} + engines: {node: '>=20.3.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260317.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@anthropic-ai/sdk@0.74.0(zod@4.3.6)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.3.6 + + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/runtime@7.29.2': {} + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)': + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.2(zod@4.3.6) + jose: 6.2.2 + kysely: 0.28.14 + nanostores: 1.2.0 + zod: 4.3.6 + + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + optionalDependencies: + kysely: 0.28.14 + + '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.1': {} + + '@better-fetch/fetch@1.1.21': {} + + '@cfworker/json-schema@4.1.1': {} + + '@changesets/apply-release-plan@7.1.0': + dependencies: + '@changesets/config': 3.1.3 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.4 + + '@changesets/assemble-release-plan@6.0.9': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.4 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/changelog-github@0.5.2(encoding@0.1.13)': + dependencies: + '@changesets/get-github-info': 0.7.0(encoding@0.1.13) + '@changesets/types': 6.1.0 + dotenv: 8.6.0 + transitivePeerDependencies: + - encoding + + '@changesets/cli@2.30.0(@types/node@24.12.0)': + dependencies: + '@changesets/apply-release-plan': 7.1.0 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.3 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.15 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.3': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.4 + + '@changesets/get-github-info@0.7.0(encoding@0.1.13)': + dependencies: + dataloader: 1.4.0 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + + '@changesets/get-release-plan@4.0.15': + dependencies: + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/config': 3.1.3 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 + + '@cloudflare/kv-asset-handler@0.4.2': {} + + '@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260317.1 + + '@cloudflare/workerd-darwin-64@1.20260317.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260317.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260317.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260317.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260317.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.9.1': + dependencies: + '@emnapi/wasi-threads': 1.2.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.3.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@hono/node-server@1.19.11(hono@4.12.9)': + dependencies: + hono: 4.12.9 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.2 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@modelcontextprotocol/conformance@0.1.15(@cfworker/json-schema@4.1.1)': + dependencies: + '@modelcontextprotocol/sdk': 1.28.0(@cfworker/json-schema@4.1.1)(zod@3.25.76) + '@octokit/rest': 22.0.1 + commander: 14.0.3 + eventsource-parser: 3.0.6 + express: 5.2.1 + jose: 6.2.2 + undici: 7.24.6 + yaml: 2.8.3 + zod: 3.25.76 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + + '@modelcontextprotocol/sdk@1.28.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.9.1 + '@emnapi/runtime': 1.9.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.0.1': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.8 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.8 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.8': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/semantic-conventions@1.40.0': {} + + '@oxc-project/types@0.103.0': {} + + '@oxc-project/types@0.122.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pinojs/redact@0.4.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.57': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.57': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.57': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.57': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.57': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.57': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.57': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.12': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.57': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.57': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.12': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.57': {} + + '@rolldown/pluginutils@1.0.0-rc.12': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.15': {} + + '@standard-schema/spec@1.1.0': {} + + '@ts-morph/common@0.29.0': + dependencies: + minimatch: 10.2.4 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 24.12.0 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/content-type@1.1.9': {} + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 24.12.0 + + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 24.12.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/eventsource@1.1.15': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 24.12.0 + '@types/qs': 6.15.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/methods@1.1.4': {} + + '@types/node@12.20.55': {} + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.5.0': + dependencies: + undici-types: 7.18.2 + optional: true + + '@types/qs@6.15.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.12.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.12.0 + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.12.0 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.2': {} + + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260327.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260327.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260327.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260327.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260327.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260327.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260327.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260327.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260327.2 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@valibot/to-json-schema@1.6.0(valibot@1.3.1(typescript@5.9.3))': + dependencies: + valibot: 1.3.1(typescript@5.9.3) + + '@vitest/expect@4.1.2': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.2(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/mocker@4.1.2(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/pretty-format@4.1.2': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.2': + dependencies: + '@vitest/utils': 4.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.2': {} + + '@vitest/utils@4.1.2': + dependencies: + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.2.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.2.0: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.2 + pathe: 2.0.3 + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.11: {} + + before-after-hook@4.0.0: {} + + better-auth@1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))): + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14) + '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.3.2(zod@4.3.6) + defu: 6.1.4 + jose: 6.2.2 + kysely: 0.28.14 + nanostores: 1.2.0 + zod: 4.3.6 + optionalDependencies: + better-sqlite3: 12.8.0 + vitest: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.2(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.3.6 + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + better-sqlite3@12.8.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + birpc@4.0.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + blake3-wasm@2.1.5: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.328 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001781: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + change-case@5.4.4: {} + + chardet@2.1.1: {} + + chownr@1.1.4: {} + + ci-info@4.4.0: {} + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + commander@14.0.3: {} + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cookiejar@2.1.4: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.1 + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + dataloader@1.4.0: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dotenv@8.6.0: {} + + dts-resolver@2.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.328: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@4.5.0: {} + + error-stack-parser-es@1.0.5: {} + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + semver: 7.7.4 + + eslint-config-prettier@10.1.8(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-import-context@0.1.9(unrs-resolver@1.11.1): + dependencies: + get-tsconfig: 4.13.7 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.11.1 + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4): + dependencies: + debug: 4.4.3 + eslint: 9.39.4 + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash-x: 0.2.0 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.4) + transitivePeerDependencies: + - supports-color + + eslint-plugin-es-x@7.8.0(eslint@9.39.4): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.4 + eslint-compat-utils: 0.5.1(eslint@9.39.4) + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-n@17.24.0(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + enhanced-resolve: 5.20.1 + eslint: 9.39.4 + eslint-plugin-es-x: 7.8.0(eslint@9.39.4) + get-tsconfig: 4.13.7 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.4 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4): + dependencies: + eslint: 9.39.4 + + eslint-plugin-unicorn@62.0.0(eslint@9.39.4): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint/plugin-kit': 0.4.1 + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 9.39.4 + esquery: 1.7.0 + find-up-simple: 1.0.1 + globals: 16.5.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.0 + semver: 7.7.4 + strip-indent: 4.1.1 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extendable-error@0.1.7: {} + + fast-content-type-parse@3.0.0: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.3.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fastify@5.8.4: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.3.0 + find-my-way: 9.5.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.0 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.5.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.0 + + find-up-simple@1.0.1: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.9: {} + + hookable@6.1.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-id@4.1.3: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + optional: true + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-without-cache@0.2.5: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.3.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + jose@6.2.2: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-with-bigint@3.5.8: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + kysely@0.28.14: {} + + lefthook-darwin-arm64@2.1.4: + optional: true + + lefthook-darwin-x64@2.1.4: + optional: true + + lefthook-freebsd-arm64@2.1.4: + optional: true + + lefthook-freebsd-x64@2.1.4: + optional: true + + lefthook-linux-arm64@2.1.4: + optional: true + + lefthook-linux-x64@2.1.4: + optional: true + + lefthook-openbsd-arm64@2.1.4: + optional: true + + lefthook-openbsd-x64@2.1.4: + optional: true + + lefthook-windows-arm64@2.1.4: + optional: true + + lefthook-windows-x64@2.1.4: + optional: true + + lefthook@2.1.4: + optionalDependencies: + lefthook-darwin-arm64: 2.1.4 + lefthook-darwin-x64: 2.1.4 + lefthook-freebsd-arm64: 2.1.4 + lefthook-freebsd-x64: 2.1.4 + lefthook-linux-arm64: 2.1.4 + lefthook-linux-x64: 2.1.4 + lefthook-openbsd-arm64: 2.1.4 + lefthook-openbsd-x64: 2.1.4 + lefthook-windows-arm64: 2.1.4 + lefthook-windows-x64: 2.1.4 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash.startcase@4.4.0: {} + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + math-intrinsics@1.1.0: {} + + mdurl@2.0.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-response@3.1.0: {} + + miniflare@4.20260317.3: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.4 + workerd: 1.20260317.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + nanostores@1.2.0: {} + + napi-build-utils@2.0.0: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-abi@3.89.0: + dependencies: + semver: 7.7.4 + + node-fetch@2.7.0(encoding@0.1.13): + dependencies: + whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 + + node-releases@2.0.36: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + obug@2.1.1: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + outdent@0.5.0: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.4.0: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + + pkce-challenge@5.0.1: {} + + pluralize@8.0.0: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.89.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@2.8.8: {} + + prettier@3.6.2: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rolldown-plugin-dts@0.20.0(@typescript/native-preview@7.0.0-dev.20260327.2)(rolldown@1.0.0-beta.57)(typescript@5.9.3): + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + ast-kit: 2.2.0 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.13.7 + obug: 2.1.1 + rolldown: 1.0.0-beta.57 + optionalDependencies: + '@typescript/native-preview': 7.0.0-dev.20260327.2 + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-beta.57: + dependencies: + '@oxc-project/types': 0.103.0 + '@rolldown/pluginutils': 1.0.0-beta.57 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.57 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.57 + '@rolldown/binding-darwin-x64': 1.0.0-beta.57 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.57 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.57 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.57 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.57 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.57 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.57 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.57 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.57 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.57 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.57 + + rolldown@1.0.0-rc.12: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.12 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.12 + '@rolldown/binding-darwin-x64': 1.0.0-rc.12 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.12 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.12 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.12 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + rou3@0.7.12: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + set-cookie-parser@3.1.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + slash@3.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + stable-hash-x@0.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.0.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-indent@4.1.1: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tapable@2.3.2: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + term-size@2.2.1: {} + + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + tr46@0.0.3: {} + + tree-kill@1.2.2: {} + + ts-algebra@2.0.0: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-morph@28.0.0: + dependencies: + '@ts-morph/common': 0.29.0 + code-block-writer: 13.0.3 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tsdown@0.18.4(@typescript/native-preview@7.0.0-dev.20260327.2)(typescript@5.9.3): + dependencies: + ansis: 4.2.0 + cac: 6.7.14 + defu: 6.1.4 + empathic: 2.0.0 + hookable: 6.1.0 + import-without-cache: 0.2.5 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-beta.57 + rolldown-plugin-dts: 0.20.0(@typescript/native-preview@7.0.0-dev.20260327.2)(rolldown@1.0.0-beta.57)(typescript@5.9.3) + semver: 7.7.4 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.34 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedoc@0.28.18(typescript@5.9.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.1.1 + minimatch: 10.2.4 + typescript: 5.9.3 + yaml: 2.8.3 + + typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@7.16.0: {} + + undici-types@7.18.2: + optional: true + + undici@7.24.4: {} + + undici@7.24.6: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + universal-user-agent@7.0.3: {} + + universalify@0.1.2: {} + + unpipe@1.0.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + unrun@0.2.34: + dependencies: + rolldown: 1.0.0-rc.12 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + valibot@1.3.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + vary@1.1.2: {} + + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + - typescript + + vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.3 + + vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.3 + + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.0(@types/node@24.12.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + transitivePeerDependencies: + - msw + + vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.5.0 + transitivePeerDependencies: + - msw + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + workerd@1.20260317.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260317.1 + '@cloudflare/workerd-darwin-arm64': 1.20260317.1 + '@cloudflare/workerd-linux-64': 1.20260317.1 + '@cloudflare/workerd-linux-arm64': 1.20260317.1 + '@cloudflare/workerd-windows-64': 1.20260317.1 + + wrangler@4.78.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.4.2 + '@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260317.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260317.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260317.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrappy@1.0.2: {} + + ws@8.18.0: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + yaml@2.8.3: {} + + yocto-queue@0.1.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.15 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e15c6b2 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,69 @@ +packages: + - packages/**/* + - '!packages/codemod/batch-test/**' + - common/**/* + - examples/**/* + - test/**/* + +catalogs: + devTools: + '@eslint/js': ^9.39.2 + '@valibot/to-json-schema': ^1.5.0 + arktype: ^2.1.29 + valibot: ^1.2.0 + wrangler: ^4.14.4 + '@types/content-type': ^1.1.8 + '@types/cors': ^2.8.17 + '@types/cross-spawn': ^6.0.6 + '@types/eventsource': ^1.1.15 + '@types/express': ^5.0.6 + '@types/express-serve-static-core': ^5.1.0 + '@types/supertest': ^6.0.2 + '@typescript/native-preview': ^7.0.0-dev.20251217.1 + eslint: ^9.39.2 + eslint-config-prettier: ^10.1.8 + eslint-plugin-n: ^17.23.1 + prettier: 3.6.2 + supertest: ^7.0.0 + tsdown: ^0.18.0 + typedoc: ^0.28.14 + tsx: ^4.16.5 + typescript: ^5.9.3 + typescript-eslint: ^8.48.1 + vite-tsconfig-paths: ^5.1.4 + vitest: ^4.0.15 + runtimeClientOnly: + cross-spawn: ^7.0.5 + eventsource: ^3.0.2 + eventsource-parser: ^3.0.0 + jose: ^6.1.3 + runtimeServerOnly: + '@hono/node-server': ^1.19.9 + content-type: ^1.0.5 + cors: ^2.8.5 + express: ^5.2.1 + fastify: ^5.2.0 + hono: ^4.11.4 + raw-body: ^3.0.0 + runtimeShared: + '@cfworker/json-schema': ^4.1.1 + ajv: ^8.17.1 + ajv-formats: ^3.0.1 + json-schema-typed: ^8.0.2 + pkce-challenge: ^5.0.0 + zod: ^4.2.0 + +enableGlobalVirtualStore: false + +linkWorkspacePackages: deep + +minimumReleaseAge: 10080 # 7 days +minimumReleaseAgeExclude: + - '@modelcontextprotocol/conformance' + +onlyBuiltDependencies: + - better-sqlite3 + - esbuild + +ignoredBuiltDependencies: + - unrs-resolver diff --git a/scripts/cli.ts b/scripts/cli.ts new file mode 100644 index 0000000..809a23e --- /dev/null +++ b/scripts/cli.ts @@ -0,0 +1,155 @@ +import express from 'express'; +import { Client } from '../src/client/index.js'; +import { SSEClientTransport } from '../src/client/sse.js'; +import { StdioClientTransport } from '../src/client/stdio.js'; +import { Server } from '../src/server/index.js'; +import { SSEServerTransport } from '../src/server/sse.js'; +import { StdioServerTransport } from '../src/server/stdio.js'; +import { ListResourcesResultSchema } from '../src/types.js'; + +async function runClient(url_or_command: string, args: string[]) { + const client = new Client( + { + name: 'mcp-typescript test client', + version: '0.1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + let clientTransport; + + let url: URL | undefined = undefined; + try { + url = new URL(url_or_command); + } catch { + // Ignore + } + + if (url?.protocol === 'http:' || url?.protocol === 'https:') { + clientTransport = new SSEClientTransport(new URL(url_or_command)); + } else if (url?.protocol === 'ws:' || url?.protocol === 'wss:') { + throw new Error('WebSocket URLs are no longer supported. Use http(s) or stdio instead.'); + } else { + clientTransport = new StdioClientTransport({ + command: url_or_command, + args + }); + } + + console.log('Connected to server.'); + + await client.connect(clientTransport); + console.log('Initialized.'); + + await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + + await client.close(); + console.log('Closed.'); +} + +async function runServer(port: number | null) { + if (port !== null) { + const app = express(); + + let servers: Server[] = []; + + app.get('/sse', async (req, res) => { + console.log('Got new SSE connection'); + + const transport = new SSEServerTransport('/message', res); + const server = new Server( + { + name: 'mcp-typescript test server', + version: '0.1.0' + }, + { + capabilities: {} + } + ); + + servers.push(server); + + server.onclose = () => { + console.log('SSE connection closed'); + servers = servers.filter(s => s !== server); + }; + + await server.connect(transport); + }); + + app.post('/message', async (req, res) => { + console.log('Received message'); + + const sessionId = req.query.sessionId as string; + const transport = servers.map(s => s.transport as SSEServerTransport).find(t => t.sessionId === sessionId); + if (!transport) { + res.status(404).send('Session not found'); + return; + } + + await transport.handlePostMessage(req, res); + }); + + app.listen(port, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`Server running on http://localhost:${port}/sse`); + }); + } else { + const server = new Server( + { + name: 'mcp-typescript test server', + version: '0.1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + + console.log('Server running on stdio'); + } +} + +const args = process.argv.slice(2); +const command = args[0]; +switch (command) { + case 'client': + if (args.length < 2) { + console.error('Usage: client [args...]'); + process.exit(1); + } + + runClient(args[1], args.slice(2)).catch(error => { + console.error(error); + process.exit(1); + }); + + break; + + case 'server': { + const port = args[1] ? parseInt(args[1]) : null; + runServer(port).catch(error => { + console.error(error); + process.exit(1); + }); + + break; + } + + default: + console.error('Unrecognized command:', command); +} diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts new file mode 100644 index 0000000..cd0e26f --- /dev/null +++ b/scripts/fetch-spec-types.ts @@ -0,0 +1,90 @@ +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as prettier from 'prettier'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = join(__dirname, '..'); + +interface GitHubCommit { + sha: string; +} + +async function fetchLatestSHA(): Promise { + const url = 'https://api.github.com/repos/modelcontextprotocol/modelcontextprotocol/commits?path=schema/draft/schema.ts&per_page=1'; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); + } + + const commits = (await response.json()) as GitHubCommit[]; + if (!commits || commits.length === 0) { + throw new Error('No commits found'); + } + + return commits[0].sha; +} + +async function fetchSpecTypes(sha: string): Promise { + const url = `https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/${sha}/schema/draft/schema.ts`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch spec types: ${response.status} ${response.statusText}`); + } + + return await response.text(); +} + +async function main() { + try { + // Check if SHA is provided as command line argument + const providedSHA = process.argv[2]; + + let latestSHA: string; + if (providedSHA) { + console.log(`Using provided SHA: ${providedSHA}`); + latestSHA = providedSHA; + } else { + console.log('Fetching latest commit SHA...'); + latestSHA = await fetchLatestSHA(); + } + + console.log(`Fetching spec.types.ts from commit: ${latestSHA}`); + + const specContent = await fetchSpecTypes(latestSHA); + + // Read header template + const headerTemplate = `/** + * This file is automatically generated from the Model Context Protocol specification. + * + * Source: https://github.com/modelcontextprotocol/modelcontextprotocol + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts + * Last updated from commit: {SHA} + * + * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. + * To update this file, run: pnpm run fetch:spec-types + */`; + + const header = headerTemplate.replace('{SHA}', latestSHA); + + // Combine header and content + const fullContent = header + specContent; + + // Format with prettier using the project's config so the output passes lint + const outputPath = join(PROJECT_ROOT, 'packages', 'core', 'src', 'types', 'spec.types.ts'); + const prettierConfig = await prettier.resolveConfig(outputPath); + const formatted = await prettier.format(fullContent, { ...prettierConfig, filepath: outputPath }); + + writeFileSync(outputPath, formatted, 'utf-8'); + + console.log('Successfully updated packages/core/src/types/spec.types.ts'); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +main(); diff --git a/scripts/generate-multidoc.sh b/scripts/generate-multidoc.sh new file mode 100644 index 0000000..856370d --- /dev/null +++ b/scripts/generate-multidoc.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Generate combined V1 + V2 TypeDoc documentation. +# +# V1 docs (from the v1.x branch) are placed at the root. +# V2 docs (from main) are placed under /v2/. +# +# Usage: +# scripts/generate-multidoc.sh [output-dir] +# +# Default output directory: tmp/docs-combined +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR="$(cd "$REPO_ROOT" && realpath -m "${1:-tmp/docs-combined}")" +V1_WORKTREE="$REPO_ROOT/.worktrees/v1-docs" +V2_WORKTREE="$REPO_ROOT/.worktrees/v2-docs" + +cleanup() { + echo "Cleaning up worktrees..." + cd "$REPO_ROOT" + git worktree remove --force "$V1_WORKTREE" 2>/dev/null || true + git worktree remove --force "$V2_WORKTREE" 2>/dev/null || true +} +trap cleanup EXIT + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +# --------------------------------------------------------------------------- +# Step 1: Generate V1 docs from v1.x branch +# --------------------------------------------------------------------------- +echo "=== Generating V1 docs ===" + +git fetch origin v1.x + +git worktree remove --force "$V1_WORKTREE" 2>/dev/null || true +rm -rf "$V1_WORKTREE" +git worktree add "$V1_WORKTREE" "origin/v1.x" --detach + +cd "$V1_WORKTREE" +npm install +npm install --save-dev typedoc@^0.28.14 + +cat > typedoc.json << 'TYPEDOC_EOF' +{ + "name": "MCP TypeScript SDK", + "entryPoints": [ + "src/client/index.ts", + "src/server/index.ts", + "src/shared/protocol.ts", + "src/shared/transport.ts", + "src/types.ts", + "src/inMemory.ts", + "src/validation/index.ts", + "src/experimental/index.ts" + ], + "tsconfig": "tsconfig.json", + "out": "tmp/docs", + "exclude": [ + "**/*.test.ts", + "**/__fixtures__/**", + "**/__mocks__/**", + "src/examples/**" + ], + "projectDocuments": [ + "docs/server.md", + "docs/client.md", + "docs/capabilities.md", + "docs/protocol.md", + "docs/faq.md" + ], + "navigationLinks": { + "V2 Docs": "/v2/" + }, + "headings": { + "readme": false + }, + "skipErrorChecking": true +} +TYPEDOC_EOF + +# Rewrite relative .ts links to point to GitHub source instead of media downloads +V1_GITHUB="https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x" +sed -i "s|(src/examples/|(${V1_GITHUB}/src/examples/|g" README.md +sed -i "s|(../src/examples/|(${V1_GITHUB}/src/examples/|g" docs/*.md + +npx typedoc + +cp -r "$V1_WORKTREE/tmp/docs/"* "$OUTPUT_DIR/" + +# --------------------------------------------------------------------------- +# Step 2: Generate V2 docs from main branch +# --------------------------------------------------------------------------- +echo "=== Generating V2 docs ===" + +git fetch origin main + +git worktree remove --force "$V2_WORKTREE" 2>/dev/null || true +rm -rf "$V2_WORKTREE" +git worktree add "$V2_WORKTREE" "origin/main" --detach + +cd "$V2_WORKTREE" +pnpm install +pnpm -r --filter='./packages/**' build + +npx typedoc # outputs to tmp/docs/ per typedoc.config.mjs + +mkdir -p "$OUTPUT_DIR/v2" +cp -r "$V2_WORKTREE/tmp/docs/"* "$OUTPUT_DIR/v2/" + +cd "$REPO_ROOT" +echo "=== Combined docs generated at $OUTPUT_DIR ===" diff --git a/scripts/sync-snippets.ts b/scripts/sync-snippets.ts new file mode 100644 index 0000000..21a2c4e --- /dev/null +++ b/scripts/sync-snippets.ts @@ -0,0 +1,593 @@ +/** + * Code Snippet Sync Script + * + * This script syncs code snippets into JSDoc comments and markdown files + * containing labeled code fences. + * + * ## Supported Source Files + * + * - **Full-file inclusion**: Any file type (e.g., `.json`, `.yaml`, `.sh`, `.ts`) + * - **Region extraction**: Only `.ts` files (using `//#region` markers) + * + * ## Code Fence Format + * + * Full-file inclusion (any file type): + * + * ``````typescript + * ```json source="./config.json" + * // entire file content is synced here + * ``` + * `````` + * + * Region extraction (.ts only): + * + * ``````typescript + * ```ts source="./path.examples.ts#regionName" + * // region content is synced here + * ``` + * `````` + * + * Optionally, a display filename can be shown before the source reference: + * + * ``````typescript + * ```ts my-app.ts source="./path.examples.ts#regionName" + * // code is synced here + * ``` + * `````` + * + * ## Region Format (in .examples.ts files) + * + * ``````typescript + * //#region regionName + * // code here + * //#endregion regionName + * `````` + * + * Run: pnpm sync:snippets + */ + +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = join(__dirname, '..'); +const PACKAGES_DIR = join(PROJECT_ROOT, 'packages'); +const DOCS_DIR = join(PROJECT_ROOT, 'docs'); + +/** Processing mode based on file type */ +type FileMode = 'jsdoc' | 'markdown'; + +/** + * Represents a labeled code fence found in a source file. + */ +interface LabeledCodeFence { + /** Optional display filename (e.g., "my-app.ts") */ + displayName?: string; + /** Relative path to the example file (e.g., "./app.examples.ts") */ + examplePath: string; + /** Region name (e.g., "App_basicUsage"), or undefined for whole file */ + regionName?: string; + /** Language from the code fence (e.g., "ts", "json", "yaml") */ + language: string; + /** Character index of the opening fence line start */ + openingFenceStart: number; + /** Character index after the opening fence line (after newline) */ + openingFenceEnd: number; + /** Character index of the closing fence line start */ + closingFenceStart: number; + /** The JSDoc line prefix extracted from context (e.g., " * ") */ + linePrefix: string; +} + +/** + * Cache for example file regions to avoid re-reading files. + * Key: `${absoluteExamplePath}#${regionName}` (empty regionName for whole file) + * Value: extracted code string + */ +type RegionCache = Map; + +/** + * Processing result for a source file. + */ +interface FileProcessingResult { + filePath: string; + modified: boolean; + snippetsProcessed: number; + errors: string[]; +} + +// JSDoc patterns - for code fences inside JSDoc comments with " * " prefix +// Matches: ``` [displayName] source="" or source="#" +// Example: " * ```ts my-app.ts source="./app.examples.ts#App_basicUsage"" +// Example: " * ```ts source="./app.examples.ts#App_basicUsage"" +// Example: " * ```ts source="./complete-example.ts"" (whole file) +const JSDOC_LABELED_FENCE_PATTERN = + /^(\s*\*\s*)```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; +const JSDOC_CLOSING_FENCE_PATTERN = /^(\s*\*\s*)```\s*$/; + +// Markdown patterns - for plain code fences in markdown files (no prefix) +// Matches: ``` [displayName] source="" or source="#" +// Example: ```ts source="./patterns.ts#chunkedDataServer" +// Example: ```ts source="./complete-example.ts" (whole file) +const MARKDOWN_LABELED_FENCE_PATTERN = + /^```(\w+)(?:\s+(\S+))?\s+source="([^"#]+)(?:#([^"]+))?"/; +const MARKDOWN_CLOSING_FENCE_PATTERN = /^```\s*$/; + +/** + * Find all labeled code fences in a source file. + * @param content The file content + * @param filePath The file path (for error messages) + * @param mode The processing mode (jsdoc or markdown) + * @returns Array of labeled code fence references + */ +function findLabeledCodeFences( + content: string, + filePath: string, + mode: FileMode, +): LabeledCodeFence[] { + const results: LabeledCodeFence[] = []; + const lines = content.split('\n'); + let charIndex = 0; + + // Select patterns based on mode + const openPattern = + mode === 'jsdoc' + ? JSDOC_LABELED_FENCE_PATTERN + : MARKDOWN_LABELED_FENCE_PATTERN; + const closePattern = + mode === 'jsdoc' + ? JSDOC_CLOSING_FENCE_PATTERN + : MARKDOWN_CLOSING_FENCE_PATTERN; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const openMatch = line.match(openPattern); + + if (openMatch) { + let linePrefix: string; + let language: string; + let displayName: string | undefined; + let examplePath: string; + let regionName: string; + + if (mode === 'jsdoc') { + // JSDoc: group 1=prefix, 2=lang, 3=displayName, 4=path, 5=region + [, linePrefix, language, displayName, examplePath, regionName] = + openMatch; + } else { + // Markdown: group 1=lang, 2=displayName, 3=path, 4=region (no prefix) + [, language, displayName, examplePath, regionName] = openMatch; + linePrefix = ''; + } + + const openingFenceStart = charIndex; + const openingFenceEnd = charIndex + line.length + 1; // +1 for newline + + // Find closing fence + let closingFenceStart = -1; + let searchIndex = openingFenceEnd; + + for (let j = i + 1; j < lines.length; j++) { + const closeLine = lines[j]; + if (closePattern.test(closeLine)) { + closingFenceStart = searchIndex; + break; + } + searchIndex += closeLine.length + 1; + } + + if (closingFenceStart === -1) { + throw new Error( + `${filePath}: No closing fence for ${examplePath}#${regionName}`, + ); + } + + results.push({ + displayName, + examplePath, + regionName, + language, + openingFenceStart, + openingFenceEnd, + closingFenceStart, + linePrefix, + }); + } + + charIndex += line.length + 1; + } + + return results; +} + +/** + * Dedent content by removing a base indentation prefix from each line. + * @param content The content to dedent + * @param baseIndent The indentation to remove + * @returns The dedented content + */ +function dedent(content: string, baseIndent: string): string { + const lines = content.split('\n'); + const dedentedLines = lines.map((line) => { + // Preserve empty lines as-is + if (line.trim() === '') return ''; + // Remove the base indentation if present + if (line.startsWith(baseIndent)) { + return line.slice(baseIndent.length); + } + // Line has less indentation than base - keep as-is + return line; + }); + + // Trim trailing empty lines + while ( + dedentedLines.length > 0 && + dedentedLines[dedentedLines.length - 1] === '' + ) { + dedentedLines.pop(); + } + + return dedentedLines.join('\n'); +} + +/** + * Extract a region from an example file. + * @param exampleContent The content of the example file + * @param regionName The region name to extract + * @param examplePath The example file path (for error messages) + * @returns The dedented region content + */ +function extractRegion( + exampleContent: string, + regionName: string, + examplePath: string, +): string { + // Region extraction only supported for .ts files (uses //#region syntax) + if (!examplePath.endsWith('.ts')) { + throw new Error( + `Region extraction (#${regionName}) is only supported for .ts files. ` + + `Use full-file inclusion (without #regionName) for: ${examplePath}`, + ); + } + + const lineEnding = exampleContent.includes('\r\n') ? '\r\n' : '\n'; + const regionStart = `//#region ${regionName}${lineEnding}`; + const regionEnd = `//#endregion ${regionName}${lineEnding}`; + + const startIndex = exampleContent.indexOf(regionStart); + if (startIndex === -1) { + throw new Error(`Region "${regionName}" not found in ${examplePath}`); + } + + const endIndex = exampleContent.indexOf(regionEnd, startIndex); + if (endIndex === -1) { + throw new Error( + `Region end marker for "${regionName}" not found in ${examplePath}`, + ); + } + + // Get content after the region start line + const afterStart = exampleContent.indexOf('\n', startIndex); + if (afterStart === -1 || afterStart >= endIndex) { + return ''; // Empty region + } + + // Extract the raw content + const rawContent = exampleContent.slice(afterStart + 1, endIndex); + + // Determine base indentation from the //#region line + let lineStart = exampleContent.lastIndexOf('\n', startIndex); + lineStart = lineStart === -1 ? 0 : lineStart + 1; + const regionLine = exampleContent.slice(lineStart, startIndex); + + // The base indent is the whitespace before //#region + const baseIndent = regionLine; + + return dedent(rawContent, baseIndent); +} + +/** + * Get or load a region from the cache. + * @param sourceFilePath The source file requesting the region + * @param examplePath The relative path to the example file + * @param regionName The region name to extract, or undefined for whole file + * @param cache The region cache + * @returns The extracted code string + */ +function getOrLoadRegion( + sourceFilePath: string, + examplePath: string, + regionName: string | undefined, + cache: RegionCache, +): string { + // Resolve the example path relative to the source file + const sourceDir = dirname(sourceFilePath); + const absoluteExamplePath = resolve(sourceDir, examplePath); + + // File content is always cached with key ending in "#" (empty region) + const fileKey = `${absoluteExamplePath}#`; + let fileContent = cache.get(fileKey); + + if (fileContent === undefined) { + try { + fileContent = readFileSync(absoluteExamplePath, 'utf-8'); + } catch { + throw new Error(`Example file not found: ${absoluteExamplePath}`); + } + cache.set(fileKey, fileContent); + } + + // If no region name, return whole file + if (!regionName) { + return fileContent.trim(); + } + + // Extract region from cached file content, cache the result + const regionKey = `${absoluteExamplePath}#${regionName}`; + let regionContent = cache.get(regionKey); + + if (regionContent === undefined) { + regionContent = extractRegion(fileContent, regionName, examplePath); + cache.set(regionKey, regionContent); + } + + return regionContent; +} + +/** + * Format code lines for insertion into a JSDoc comment. + * @param code The code to format + * @param linePrefix The JSDoc line prefix (e.g., " * ") + * @returns The formatted code with JSDoc prefixes + */ +function formatCodeLines(code: string, linePrefix: string): string { + const lines = code.split('\n'); + return lines + .map((line) => + line === '' ? linePrefix.trimEnd() : `${linePrefix}${line}`, + ) + .join('\n'); +} + +interface ProcessFileOptions { + check?: boolean; +} + +/** + * Process a single source file to sync snippets. + * @param filePath The source file path + * @param cache The region cache + * @param mode The processing mode (jsdoc or markdown) + * @returns The processing result + */ +function processFile( + filePath: string, + cache: RegionCache, + mode: FileMode, + options?: ProcessFileOptions, +): FileProcessingResult { + const result: FileProcessingResult = { + filePath, + modified: false, + snippetsProcessed: 0, + errors: [], + }; + + let content: string; + try { + content = readFileSync(filePath, 'utf-8'); + } catch (err) { + result.errors.push(`Failed to read file: ${err}`); + return result; + } + + let fences: LabeledCodeFence[]; + try { + fences = findLabeledCodeFences(content, filePath, mode); + } catch (err) { + result.errors.push(err instanceof Error ? err.message : String(err)); + return result; + } + + if (fences.length === 0) { + return result; + } + + const originalContent = content; + + // Process fences in reverse order to preserve positions + for (let i = fences.length - 1; i >= 0; i--) { + const fence = fences[i]; + + try { + const code = getOrLoadRegion( + filePath, + fence.examplePath, + fence.regionName, + cache, + ); + + const formattedCode = formatCodeLines(code, fence.linePrefix); + + // Replace content between opening fence end and closing fence start + content = + content.slice(0, fence.openingFenceEnd) + + formattedCode + + '\n' + + content.slice(fence.closingFenceStart); + + result.snippetsProcessed++; + } catch (err) { + result.errors.push( + `${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if ( + result.snippetsProcessed > 0 && + result.errors.length === 0 && + content !== originalContent + ) { + if (!options?.check) { + writeFileSync(filePath, content); + } + result.modified = true; + } + + return result; +} + +/** + * Find all TypeScript source files in a directory, excluding examples, tests, and generated files. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findSourceFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + const name = entry.name; + + // Only process .ts files + if (!name.endsWith('.ts')) continue; + + // Exclude example files, test files + if (name.endsWith('.examples.ts')) continue; + if (name.endsWith('.test.ts')) continue; + + // Get the relative path from the parent directory + const parentPath = entry.parentPath; + + // Exclude generated directory + if (parentPath.includes('/generated') || parentPath.includes('\\generated')) + continue; + + const fullPath = join(parentPath, name); + files.push(fullPath); + } + + return files; +} + +/** + * Find all markdown files in a directory. + * @param dir The directory to search + * @returns Array of absolute file paths + */ +function findMarkdownFiles(dir: string): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true, recursive: true }); + + for (const entry of entries) { + if (!entry.isFile()) continue; + + // Only process .md files + if (!entry.name.endsWith('.md')) continue; + + const fullPath = join(entry.parentPath, entry.name); + files.push(fullPath); + } + + return files; +} + +/** + * Find all package src directories under the packages directory. + * @param packagesDir The packages directory + * @returns Array of absolute paths to src directories + */ +function findPackageSrcDirs(packagesDir: string): string[] { + const srcDirs: string[] = []; + const entries = readdirSync(packagesDir, { + withFileTypes: true, + recursive: true, + }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name !== 'src') continue; + + const fullPath = join(entry.parentPath, entry.name); + + // Only include src dirs that are direct children of a package + // (e.g., packages/core/src, packages/middleware/express/src) + // Skip nested src dirs like node_modules/*/src + if (fullPath.includes('node_modules')) continue; + + srcDirs.push(fullPath); + } + + return srcDirs; +} + +async function main() { + const checkMode = process.argv.includes('--check'); + console.log( + checkMode + ? 'Checking code snippets are in sync...\n' + : 'Syncing code snippets from example files...\n', + ); + + const cache: RegionCache = new Map(); + const results: FileProcessingResult[] = []; + + // Process TypeScript source files (JSDoc mode) across all packages + const packageSrcDirs = findPackageSrcDirs(PACKAGES_DIR); + for (const srcDir of packageSrcDirs) { + const sourceFiles = findSourceFiles(srcDir); + for (const filePath of sourceFiles) { + const result = processFile(filePath, cache, 'jsdoc', { check: checkMode }); + results.push(result); + } + } + + // Process markdown documentation files + const markdownFiles = findMarkdownFiles(DOCS_DIR); + for (const filePath of markdownFiles) { + const result = processFile(filePath, cache, 'markdown', { check: checkMode }); + results.push(result); + } + + // Report results + const modified = results.filter((r) => r.modified); + const errors = results.flatMap((r) => r.errors); + + if (modified.length > 0) { + if (checkMode) { + console.error(`${modified.length} file(s) out of sync:`); + } else { + console.log(`Modified ${modified.length} file(s):`); + } + for (const r of modified) { + console.log(` ${r.filePath} (${r.snippetsProcessed} snippet(s))`); + } + } else { + console.log('All snippets are up to date'); + } + + if (errors.length > 0) { + console.error('\nErrors:'); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); + } + + if (checkMode && modified.length > 0) { + console.error('\nRun "pnpm sync:snippets" to fix.'); + process.exit(1); + } + + console.log('\nSnippet sync complete!'); +} + +main().catch((error) => { + console.error('Snippet sync failed:', error); + process.exit(1); +}); diff --git a/test/conformance/README.md b/test/conformance/README.md new file mode 100644 index 0000000..7d443f6 --- /dev/null +++ b/test/conformance/README.md @@ -0,0 +1,83 @@ +# Conformance Tests + +This directory contains conformance test implementations for the TypeScript MCP SDK. + +## Client Conformance Tests + +Tests the SDK's client implementation against a conformance test server. + +```bash +# Run all client tests +pnpm run test:conformance:client:all + +# Run specific suite +pnpm run test:conformance:client -- --suite auth + +# Run single scenario +pnpm run test:conformance:client -- --scenario auth/basic-cimd +``` + +## Server Conformance Tests + +Tests the SDK's server implementation by running a conformance server. + +```bash +# Run all active server tests +pnpm run test:conformance:server + +# Run all server tests (including pending) +pnpm run test:conformance:server:all +``` + +## Local Development + +### Running Tests Against Local Conformance Repo + +Link the local conformance package: + +```bash +cd ~/code/mcp/typescript-sdk +pnpm link ~/code/mcp/conformance +``` + +Then run tests as above. + +### Debugging Server Tests + +Start the server manually: + +```bash +pnpm run test:conformance:server:run +``` + +In another terminal, run specific tests: + +```bash +npx @modelcontextprotocol/conformance server \ + --url http://localhost:3000/mcp \ + --scenario server-initialize +``` + +## Files + +- `src/everythingClient.ts` - Client that handles all client conformance scenarios +- `src/everythingServer.ts` - Server that implements all server conformance features +- `src/authTestServer.ts` - Server with OAuth authentication for auth conformance tests +- `src/helpers/` - Shared utilities for conformance tests +- `scripts/` - Conformance test runner scripts + +## Auth Test Server + +The `authTestServer.ts` is designed for testing server-side OAuth implementation. It requires an authorization server URL and validates tokens via introspection. + +```bash +# Start with a fake auth server +MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 \ + npx tsx src/authTestServer.ts +``` + +The server: + +- Requires Bearer token authentication on all MCP endpoints +- Validates tokens via the AS's introspection endpoint (RFC 7662) +- Serves Protected Resource Metadata at `/.well-known/oauth-protected-resource` diff --git a/test/conformance/eslint.config.mjs b/test/conformance/eslint.config.mjs new file mode 100644 index 0000000..951c9f3 --- /dev/null +++ b/test/conformance/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml new file mode 100644 index 0000000..5f4de22 --- /dev/null +++ b/test/conformance/expected-failures.yaml @@ -0,0 +1,4 @@ +# Conformance scenarios not yet implemented in the v2 TypeScript SDK. +# CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. + +client: [] diff --git a/test/conformance/package.json b/test/conformance/package.json new file mode 100644 index 0000000..db0f04a --- /dev/null +++ b/test/conformance/package.json @@ -0,0 +1,54 @@ +{ + "name": "@modelcontextprotocol/test-conformance", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10.24.0" + }, + "packageManager": "pnpm@10.24.0", + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "start": "npm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client", + "test:conformance:client": "conformance client --command 'npx tsx ./src/everythingClient.ts' --expected-failures ./expected-failures.yaml", + "test:conformance:client:all": "conformance client --command 'npx tsx ./src/everythingClient.ts' --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:client:run": "npx tsx ./src/everythingClient.ts", + "test:conformance:server": "scripts/run-server-conformance.sh --expected-failures ./expected-failures.yaml", + "test:conformance:server:all": "scripts/run-server-conformance.sh --suite all --expected-failures ./expected-failures.yaml", + "test:conformance:server:run": "npx tsx ./src/everythingServer.ts", + "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" + }, + "devDependencies": { + "@modelcontextprotocol/conformance": "0.1.15", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "cors": "catalog:runtimeServerOnly", + "express": "catalog:runtimeServerOnly", + "zod": "catalog:runtimeShared" + } +} diff --git a/test/conformance/scripts/run-server-conformance.sh b/test/conformance/scripts/run-server-conformance.sh new file mode 100644 index 0000000..203a014 --- /dev/null +++ b/test/conformance/scripts/run-server-conformance.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Script to run server conformance tests +# Starts the conformance server, runs conformance tests, then stops the server + +set -e + +PORT="${PORT:-3000}" +SERVER_URL="http://localhost:${PORT}/mcp" + +# Navigate to the repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +# Start the server in the background +echo "Starting conformance test server on port ${PORT}..." +npx tsx ./src/everythingServer.ts & +SERVER_PID=$! + +# Function to cleanup on exit +cleanup() { + echo "Stopping server (PID: ${SERVER_PID})..." + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for server to be ready +echo "Waiting for server to be ready..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "${SERVER_URL}" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} attempts" + exit 1 + fi + sleep 0.5 +done + +echo "Server is ready. Running conformance tests..." + +# Run conformance tests - pass through all arguments +npx @modelcontextprotocol/conformance server --url "${SERVER_URL}" "$@" + +echo "Conformance tests completed." diff --git a/test/conformance/src/authTestServer.ts b/test/conformance/src/authTestServer.ts new file mode 100644 index 0000000..56bf609 --- /dev/null +++ b/test/conformance/src/authTestServer.ts @@ -0,0 +1,429 @@ +#!/usr/bin/env node + +/** + * MCP Auth Test Server - Conformance Test Server with Authentication + * + * A minimal MCP server that requires Bearer token authentication. + * This server is used for testing OAuth authentication flows in conformance tests. + * + * Required environment variables: + * - MCP_CONFORMANCE_AUTH_SERVER_URL: URL of the authorization server + * + * Optional environment variables: + * - PORT: Server port (default: 3001) + */ + +import { randomUUID } from 'node:crypto'; + +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { NextFunction, Request, Response } from 'express'; +import express from 'express'; +import * as z from 'zod/v4'; + +// Extend Express Request type to include auth info set by middleware +declare module 'express' { + interface Request { + auth?: AuthInfo; + } +} + +// Check for required environment variable +const AUTH_SERVER_URL = process.env.MCP_CONFORMANCE_AUTH_SERVER_URL; +if (!AUTH_SERVER_URL) { + console.error('Error: MCP_CONFORMANCE_AUTH_SERVER_URL environment variable is required'); + console.error('Usage: MCP_CONFORMANCE_AUTH_SERVER_URL=http://localhost:3000 npx tsx authTestServer.ts'); + process.exit(1); +} + +// Server configuration +const PORT = process.env.PORT || 3001; +const getBaseUrl = () => `http://localhost:${PORT}`; + +// Session management +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// Scope required for admin-action tool +const ADMIN_SCOPE = 'admin'; + +// Function to create a new MCP server instance (one per session) +function createMcpServer(): McpServer { + const mcpServer = new McpServer( + { + name: 'mcp-auth-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Simple echo tool for testing authenticated calls + mcpServer.registerTool( + 'echo', + { + description: 'Echoes back the provided message - used for testing authenticated calls', + inputSchema: z.object({ + message: z.string().optional().describe('The message to echo back') + }) + }, + async (args: { message?: string }) => { + const message = args.message || 'No message provided'; + return { + content: [{ type: 'text', text: `Echo: ${message}` }] + }; + } + ); + + // Simple test tool with no arguments + mcpServer.registerTool( + 'test-tool', + { + description: 'A simple test tool that returns a success message' + }, + async () => { + return { + content: [{ type: 'text', text: 'test' }] + }; + } + ); + + // Privileged tool requiring 'admin' scope - for step-up auth testing + mcpServer.registerTool( + 'admin-action', + { + description: 'A privileged action that requires admin scope - used for step-up auth testing', + inputSchema: z.object({ + action: z.string().optional().describe('The admin action to perform') + }) + }, + async (args: { action?: string }) => { + const action = args.action || 'default-admin-action'; + return { + content: [{ type: 'text', text: `Admin action performed: ${action}` }] + }; + } + ); + + return mcpServer; +} + +/** + * Fetches the authorization server metadata to get the introspection endpoint. + */ +async function fetchAuthServerMetadata(): Promise<{ introspection_endpoint?: string }> { + const metadataUrl = `${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`; + const response = await fetch(metadataUrl); + if (!response.ok) { + throw new Error(`Failed to fetch AS metadata: ${response.status}`); + } + return response.json() as Promise<{ introspection_endpoint?: string }>; +} + +/** + * Verifies a token via the authorization server's introspection endpoint (RFC 7662). + */ +async function introspectToken(introspectionEndpoint: string, token: string): Promise { + const response = await fetch(introspectionEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ token }).toString() + }); + + if (!response.ok) { + throw new Error('Token introspection failed'); + } + + const data = (await response.json()) as { + active: boolean; + client_id?: string; + scope?: string; + exp?: number; + }; + + if (!data.active) { + throw new Error('Token is not active'); + } + + return { + token, + clientId: data.client_id || 'unknown', + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp || Math.floor(Date.now() / 1000) + 3600 + }; +} + +/** + * Express middleware that requires a valid Bearer token. + * Validates via the authorization server's introspection endpoint and sets req.auth. + */ +function requireBearerAuth(introspectionEndpoint: string, prmUrl: string) { + const buildWwwAuthHeader = (errorCode: string, message: string): string => { + return `Bearer error="${errorCode}", error_description="${message}", resource_metadata="${prmUrl}"`; + }; + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', 'Missing Authorization header')); + res.status(401).json({ + error: 'invalid_token', + error_description: 'Missing Authorization header' + }); + return; + } + + const token = authHeader.slice(7); // Remove 'Bearer ' prefix + + try { + req.auth = await introspectToken(introspectionEndpoint, token); + next(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid token'; + res.set('WWW-Authenticate', buildWwwAuthHeader('invalid_token', message)); + res.status(401).json({ + error: 'invalid_token', + error_description: message + }); + } + }; +} + +// Helper to check if request is a tools/call for admin-action +function isAdminToolCall(body: unknown): boolean { + if (typeof body !== 'object' || body === null || !('method' in body) || !('params' in body)) { + return false; + } + const { method, params } = body as { method: string; params: unknown }; + if (method !== 'tools/call') { + return false; + } + if (typeof params !== 'object' || params === null || !('name' in params)) { + return false; + } + return (params as { name: string }).name === 'admin-action'; +} + +/** + * Middleware to check for admin scope on privileged tool calls. + * Returns 403 insufficient_scope if the token doesn't have admin scope. + */ +function checkAdminScope(prmUrl: string) { + return (req: Request, res: Response, next: NextFunction): void => { + // Only check for tools/call with admin-action + if (!isAdminToolCall(req.body)) { + return next(); + } + + // req.auth is set by requireBearerAuth middleware + const scopes = req.auth?.scopes || []; + + if (!scopes.includes(ADMIN_SCOPE)) { + // Return 403 with insufficient_scope error + res.setHeader( + 'WWW-Authenticate', + `Bearer error="insufficient_scope", ` + + `scope="${ADMIN_SCOPE}", ` + + `resource_metadata="${prmUrl}", ` + + `error_description="The admin-action tool requires admin scope"` + ); + res.status(403).json({ + error: 'insufficient_scope', + error_description: 'The admin-action tool requires admin scope' + }); + return; + } + + next(); + }; +} + +// ===== EXPRESS APP ===== + +async function startServer() { + // Fetch AS metadata to get introspection endpoint + console.log(`Fetching authorization server metadata from ${AUTH_SERVER_URL}...`); + const asMetadata = await fetchAuthServerMetadata(); + + if (!asMetadata.introspection_endpoint) { + console.error('Error: Authorization server does not provide introspection_endpoint'); + process.exit(1); + } + + console.log(`Using introspection endpoint: ${asMetadata.introspection_endpoint}`); + + // Create bearer auth middleware + const prmUrl = `${getBaseUrl()}/.well-known/oauth-protected-resource`; + const bearerAuth = requireBearerAuth(asMetadata.introspection_endpoint, prmUrl); + + // Create scope-checking middleware for privileged tools + const adminScopeCheck = checkAdminScope(prmUrl); + + const app = express(); + app.use(express.json()); + + // Configure CORS to expose Mcp-Session-Id header for browser-based clients + app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'Authorization'] + }) + ); + + // Protected Resource Metadata endpoint (RFC 9728) + app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => { + res.json({ + resource: getBaseUrl(), + authorization_servers: [AUTH_SERVER_URL], + // List supported scopes for step-up auth testing + scopes_supported: [ADMIN_SCOPE] + }); + }); + + // Handle POST requests to /mcp with bearer auth and scope checking + app.post('/mcp', bearerAuth, adminScopeCheck, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport for established sessions + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // Create new transport for initialization requests + const mcpServer = createMcpServer(); + + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } + }); + + // Handle GET requests - SSE streams for sessions (also requires auth) + app.get('/mcp', bearerAuth, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } + }); + + // Handle DELETE requests - session termination (also requires auth) + app.delete('/mcp', bearerAuth, async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } + }); + + // Start server + app.listen(PORT, () => { + console.log(`MCP Auth Test Server running at http://localhost:${PORT}/mcp`); + console.log(` - PRM endpoint: http://localhost:${PORT}/.well-known/oauth-protected-resource`); + console.log(` - Auth server: ${AUTH_SERVER_URL}`); + console.log(` - Introspection: ${asMetadata.introspection_endpoint}`); + }); +} + +// Start the server +try { + await startServer(); +} catch (error) { + console.error('Failed to start server:', error); + process.exit(1); +} diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts new file mode 100644 index 0000000..05103eb --- /dev/null +++ b/test/conformance/src/everythingClient.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env node + +/** + * Everything client - a single conformance test client that handles all scenarios. + * + * Usage: everything-client + * + * The scenario name is read from the MCP_CONFORMANCE_SCENARIO environment variable, + * which is set by the conformance test runner. + * + * This client routes to the appropriate behavior based on the scenario name, + * consolidating all the individual test clients into one. + */ + +import { + Client, + ClientCredentialsProvider, + CrossAppAccessProvider, + PrivateKeyJwtProvider, + requestJwtAuthorizationGrant, + StreamableHTTPClientTransport +} from '@modelcontextprotocol/client'; +import * as z from 'zod/v4'; + +import { ConformanceOAuthProvider } from './helpers/conformanceOAuthProvider.js'; +import { logger } from './helpers/logger.js'; +import { handle401, withOAuthRetry } from './helpers/withOAuthRetry.js'; + +/** + * Fixed client metadata URL for CIMD conformance tests. + * When server supports client_id_metadata_document_supported, this URL + * will be used as the client_id instead of doing dynamic registration. + */ +const CIMD_CLIENT_METADATA_URL = 'https://conformance-test.local/client-metadata.json'; + +/** + * Schema for client conformance test context passed via MCP_CONFORMANCE_CONTEXT. + * + * Each variant includes a `name` field matching the scenario name to enable + * discriminated union parsing and type-safe access to scenario-specific fields. + */ +const ClientConformanceContextSchema = z.discriminatedUnion('name', [ + z.object({ + name: z.literal('auth/client-credentials-jwt'), + client_id: z.string(), + private_key_pem: z.string(), + signing_algorithm: z.string().optional() + }), + z.object({ + name: z.literal('auth/client-credentials-basic'), + client_id: z.string(), + client_secret: z.string() + }), + z.object({ + name: z.literal('auth/pre-registration'), + client_id: z.string(), + client_secret: z.string() + }), + z.object({ + name: z.literal('auth/cross-app-access-complete-flow'), + client_id: z.string(), + client_secret: z.string(), + idp_client_id: z.string(), + idp_id_token: z.string(), + idp_issuer: z.string(), + idp_token_endpoint: z.string() + }) +]); + +/** + * Parse the conformance context from MCP_CONFORMANCE_CONTEXT env var. + */ +function parseContext() { + const raw = process.env.MCP_CONFORMANCE_CONTEXT; + if (!raw) { + throw new Error('MCP_CONFORMANCE_CONTEXT not set'); + } + return ClientConformanceContextSchema.parse(JSON.parse(raw)); +} + +// Scenario handler type +type ScenarioHandler = (serverUrl: string) => Promise; + +// Registry of scenario handlers +const scenarioHandlers: Record = {}; + +// Helper to register a scenario handler +function registerScenario(name: string, handler: ScenarioHandler): void { + scenarioHandlers[name] = handler; +} + +// Helper to register multiple scenarios with the same handler +function registerScenarios(names: string[], handler: ScenarioHandler): void { + for (const name of names) { + scenarioHandlers[name] = handler; + } +} + +// ============================================================================ +// Basic scenarios (initialize, tools_call) +// ============================================================================ + +async function runBasicClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// tools_call scenario needs to actually call a tool +async function runToolsCallClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + const tools = await client.listTools(); + logger.debug('Successfully listed tools'); + + // Call the add_numbers tool + const addTool = tools.tools.find(t => t.name === 'add_numbers'); + if (addTool) { + const result = await client.callTool({ + name: 'add_numbers', + arguments: { a: 5, b: 3 } + }); + logger.debug('Tool call result:', JSON.stringify(result, null, 2)); + } + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('initialize', runBasicClient); +registerScenario('tools_call', runToolsCallClient); + +// ============================================================================ +// Auth scenarios - well-behaved client +// ============================================================================ + +async function runAuthClient(serverUrl: string): Promise { + const client = new Client({ name: 'test-auth-client', version: '1.0.0' }, { capabilities: {} }); + + const oauthFetch = withOAuthRetry('test-auth-client', new URL(serverUrl), handle401, CIMD_CLIENT_METADATA_URL)(fetch); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Successfully called tool'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +// Register all auth scenarios that should use the well-behaved auth client +// Note: client-credentials-jwt and client-credentials-basic have their own handlers below +registerScenarios( + [ + 'auth/basic-cimd', + 'auth/metadata-default', + 'auth/metadata-var1', + 'auth/metadata-var2', + 'auth/metadata-var3', + 'auth/2025-03-26-oauth-metadata-backcompat', + 'auth/2025-03-26-oauth-endpoint-fallback', + '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/offline-access-scope', + 'auth/offline-access-not-supported' + ], + runAuthClient +); + +// ============================================================================ +// Client Credentials scenarios +// ============================================================================ + +/** + * Client credentials with private_key_jwt authentication. + */ +async function runClientCredentialsJwt(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-jwt') { + throw new Error(`Expected jwt context, got ${ctx.name}`); + } + + const provider = new PrivateKeyJwtProvider({ + clientId: ctx.client_id, + privateKey: ctx.private_key_pem, + algorithm: ctx.signing_algorithm || 'ES256' + }); + + const client = new Client({ name: 'conformance-client-credentials-jwt', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with private_key_jwt auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-jwt', runClientCredentialsJwt); + +/** + * Client credentials with client_secret_basic authentication. + */ +async function runClientCredentialsBasic(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/client-credentials-basic') { + throw new Error(`Expected basic context, got ${ctx.name}`); + } + + const provider = new ClientCredentialsProvider({ + clientId: ctx.client_id, + clientSecret: ctx.client_secret + }); + + const client = new Client({ name: 'conformance-client-credentials-basic', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with client_secret_basic auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/client-credentials-basic', runClientCredentialsBasic); + +/** + * Cross-App Access (SEP-990 Enterprise Managed Authorization). + * + * Exchanges an IdP-issued ID token for an ID-JAG (RFC 8693 token exchange at the IdP), + * then exchanges the ID-JAG for an access token at the AS (RFC 7523 JWT bearer grant + * with client_secret_basic). The provider drives discovery + the JWT bearer step; the + * assertion callback handles the IdP exchange using the context-supplied ID token. + */ +async function runCrossAppAccessCompleteFlow(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/cross-app-access-complete-flow') { + throw new Error(`Expected cross-app-access context, got ${ctx.name}`); + } + + const provider = new CrossAppAccessProvider({ + clientId: ctx.client_id, + clientSecret: ctx.client_secret, + assertion: async authCtx => { + const result = await requestJwtAuthorizationGrant({ + tokenEndpoint: ctx.idp_token_endpoint, + audience: authCtx.authorizationServerUrl, + resource: authCtx.resourceUrl, + idToken: ctx.idp_id_token, + clientId: ctx.idp_client_id, + fetchFn: authCtx.fetchFn + }); + return result.jwtAuthGrant; + } + }); + + const client = new Client({ name: 'conformance-cross-app-access', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + authProvider: provider + }); + + await client.connect(transport); + logger.debug('Successfully connected with cross-app-access auth'); + + await client.listTools(); + logger.debug('Successfully listed tools'); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('auth/cross-app-access-complete-flow', runCrossAppAccessCompleteFlow); + +// ============================================================================ +// Pre-registration scenario (no dynamic client registration) +// ============================================================================ + +async function runPreRegistrationClient(serverUrl: string): Promise { + const ctx = parseContext(); + if (ctx.name !== 'auth/pre-registration') { + throw new Error(`Expected pre-registration context, got ${ctx.name}`); + } + + // Create a provider pre-populated with registered credentials, + // so the SDK skips dynamic client registration. + const provider = new ConformanceOAuthProvider('http://localhost:3000/callback', { + client_name: 'conformance-pre-registration', + redirect_uris: ['http://localhost:3000/callback'] + }); + provider.saveClientInformation({ + client_id: ctx.client_id, + client_secret: ctx.client_secret, + redirect_uris: ['http://localhost:3000/callback'] + }); + + const oauthFetch = withOAuthRetry('conformance-pre-registration', new URL(serverUrl), handle401, undefined, provider)(fetch); + + const client = new Client({ name: 'conformance-pre-registration', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + fetch: oauthFetch + }); + + await client.connect(transport); + await client.listTools(); + await client.callTool({ name: 'test-tool', arguments: {} }); + await transport.close(); +} + +registerScenario('auth/pre-registration', runPreRegistrationClient); + +// ============================================================================ +// Elicitation defaults scenario +// ============================================================================ + +async function runElicitationDefaultsClient(serverUrl: string): Promise { + const client = new Client( + { name: 'elicitation-defaults-test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + // Register elicitation handler that returns empty content + // The SDK should fill in defaults for all omitted fields + client.setRequestHandler('elicitation/create', async request => { + logger.debug('Received elicitation request:', JSON.stringify(request.params, null, 2)); + logger.debug('Accepting with empty content - SDK should apply defaults'); + + // Return empty content - SDK should merge in defaults + return { + action: 'accept' as const, + content: {} + }; + }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + // List available tools + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + // Call the test tool which will trigger elicitation + const testTool = tools.tools.find(t => t.name === 'test_client_elicitation_defaults'); + if (!testTool) { + throw new Error('Test tool not found: test_client_elicitation_defaults'); + } + + logger.debug('Calling test_client_elicitation_defaults tool...'); + const result = await client.callTool({ + name: 'test_client_elicitation_defaults', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('elicitation-sep1034-client-defaults', runElicitationDefaultsClient); + +// ============================================================================ +// SSE retry scenario +// ============================================================================ + +async function runSSERetryClient(serverUrl: string): Promise { + const client = new Client({ name: 'sse-retry-test-client', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Successfully connected to MCP server'); + + // List tools to get the reconnection test tool + const tools = await client.listTools(); + logger.debug( + 'Available tools:', + tools.tools.map(t => t.name) + ); + + // Call the test_reconnection tool which triggers stream closure + const testTool = tools.tools.find(t => t.name === 'test_reconnection'); + if (!testTool) { + throw new Error('Test tool not found: test_reconnection'); + } + + logger.debug('Calling test_reconnection tool...'); + const result = await client.callTool({ + name: 'test_reconnection', + arguments: {} + }); + + logger.debug('Tool result:', JSON.stringify(result, null, 2)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('sse-retry', runSSERetryClient); + +// ============================================================================ +// Main entry point +// ============================================================================ + +async function main(): Promise { + const scenarioName = process.env.MCP_CONFORMANCE_SCENARIO; + const serverUrl = process.argv[2]; + + if (!scenarioName || !serverUrl) { + logger.error('Usage: MCP_CONFORMANCE_SCENARIO= everything-client '); + logger.error('\nThe MCP_CONFORMANCE_SCENARIO env var is set automatically by the conformance runner.'); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).toSorted()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + const handler = scenarioHandlers[scenarioName]; + if (!handler) { + logger.error(`Unknown scenario: ${scenarioName}`); + logger.error('\nAvailable scenarios:'); + for (const name of Object.keys(scenarioHandlers).toSorted()) { + logger.error(` - ${name}`); + } + process.exit(1); + } + + try { + await handler(serverUrl); + process.exit(0); + } catch (error) { + logger.error('Error:', error); + process.exit(1); + } +} + +try { + await main(); +} catch (error) { + logger.error('Error:', error); + process.exit(1); +} diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts new file mode 100644 index 0000000..f3925ae --- /dev/null +++ b/test/conformance/src/everythingServer.ts @@ -0,0 +1,1026 @@ +#!/usr/bin/env node + +/** + * MCP Conformance Test Server + * + * Server implementing all MCP features for conformance testing. + * This server is designed to pass all conformance test scenarios. + */ + +import { randomUUID } from 'node:crypto'; + +import { localhostHostValidation } from '@modelcontextprotocol/express'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; +import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import * as z from 'zod/v4'; + +// Server state +const resourceSubscriptions = new Set(); +const watchedResourceContent = 'Watched resource content'; + +// Session management +const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; +const servers: { [sessionId: string]: McpServer } = {}; + +// In-memory event store for SEP-1699 resumability +const eventStoreData = new Map(); + +function createEventStore(): EventStore { + return { + async storeEvent(streamId: StreamId, message: unknown): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + eventStoreData.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: unknown) => Promise } + ): Promise { + const streamId = lastEventId.split('::')[0] || lastEventId; + const eventsToReplay: Array<[string, { message: unknown }]> = []; + for (const [eventId, data] of eventStoreData.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (message && typeof message === 'object' && Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; +} + +// Sample base64 encoded 1x1 red PNG pixel for testing +const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +// Sample base64 encoded minimal WAV file for testing +const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; + +// Function to create a new MCP server instance (one per session) +function createMcpServer() { + const mcpServer = new McpServer( + { + name: 'mcp-conformance-test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: { + listChanged: true + }, + resources: { + subscribe: true, + listChanged: true + }, + prompts: { + listChanged: true + }, + logging: {}, + completions: {} + } + } + ); + + // Helper to send log messages using the underlying server + function sendLog( + level: 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency', + message: string, + _data?: unknown + ) { + mcpServer.server + .notification({ + method: 'notifications/message', + params: { + level, + logger: 'conformance-test-server', + data: _data || message + } + }) + .catch(() => { + // Ignore error if no client is connected + }); + } + + // ===== TOOLS ===== + + // Simple text tool + mcpServer.registerTool( + 'test_simple_text', + { + description: 'Tests simple text content response' + }, + async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + } + ); + + // Image content tool + mcpServer.registerTool( + 'test_image_content', + { + description: 'Tests image content response' + }, + async (): Promise => { + return { + content: [{ type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }] + }; + } + ); + + // Audio content tool + mcpServer.registerTool( + 'test_audio_content', + { + description: 'Tests audio content response' + }, + async (): Promise => { + return { + content: [{ type: 'audio', data: TEST_AUDIO_BASE64, mimeType: 'audio/wav' }] + }; + } + ); + + // Embedded resource tool + mcpServer.registerTool( + 'test_embedded_resource', + { + description: 'Tests embedded resource content response' + }, + async (): Promise => { + return { + content: [ + { + type: 'resource', + resource: { + uri: 'test://embedded-resource', + mimeType: 'text/plain', + text: 'This is an embedded resource content.' + } + } + ] + }; + } + ); + + // Multiple content types tool + mcpServer.registerTool( + 'test_multiple_content_types', + { + description: 'Tests response with multiple content types (text, image, resource)' + }, + async (): Promise => { + return { + content: [ + { type: 'text', text: 'Multiple content types test:' }, + { type: 'image', data: TEST_IMAGE_BASE64, mimeType: 'image/png' }, + { + type: 'resource', + resource: { + uri: 'test://mixed-content-resource', + mimeType: 'application/json', + text: JSON.stringify({ test: 'data', value: 123 }) + } + } + ] + }; + } + ); + + // Tool with logging + mcpServer.registerTool( + 'test_tool_with_logging', + { + description: 'Tests tool that emits log messages during execution', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution started' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool processing data' + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { + level: 'info', + data: 'Tool execution completed' + } + }); + return { + content: [{ type: 'text', text: 'Tool with logging executed successfully' }] + }; + } + ); + + // Tool with progress + mcpServer.registerTool( + 'test_tool_with_progress', + { + description: 'Tests tool that reports progress notifications', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const progressToken = ctx.mcpReq._meta?.progressToken ?? 0; + console.log('Progress token:', progressToken); + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: 0, + total: 100, + message: `Completed step ${0} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: 50, + total: 100, + message: `Completed step ${50} of ${100}` + } + }); + await new Promise(resolve => setTimeout(resolve, 50)); + + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: 100, + total: 100, + message: `Completed step ${100} of ${100}` + } + }); + + return { + content: [{ type: 'text', text: String(progressToken) }] + }; + } + ); + + // Error handling tool + mcpServer.registerTool( + 'test_error_handling', + { + description: 'Tests error response handling' + }, + async (): Promise => { + throw new Error('This tool intentionally returns an error for testing'); + } + ); + + // SEP-1699: Reconnection test tool - closes SSE stream mid-call to test client reconnection + mcpServer.registerTool( + 'test_reconnection', + { + description: + 'Tests SSE stream disconnection and client reconnection (SEP-1699). Server will close the stream mid-call and send the result after client reconnects.', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${ctx.sessionId}] Starting test_reconnection tool...`); + + // Get the transport for this session + const transport = ctx.sessionId ? transports[ctx.sessionId] : undefined; + if (transport && ctx.mcpReq.id) { + // Close the SSE stream to trigger client reconnection + console.log(`[${ctx.sessionId}] Closing SSE stream to trigger client polling...`); + transport.closeSSEStream(ctx.mcpReq.id); + } + + // Wait for client to reconnect (should respect retry field) + await sleep(100); + + console.log(`[${ctx.sessionId}] test_reconnection tool complete`); + + return { + content: [ + { + type: 'text', + text: 'Reconnection test completed successfully. If you received this, the client properly reconnected after stream closure.' + } + ] + }; + } + ); + + // Sampling tool - requests LLM completion from client + mcpServer.registerTool( + 'test_sampling', + { + description: 'Tests server-initiated sampling (LLM completion request)', + inputSchema: z.object({ + prompt: z.string().describe('The prompt to send to the LLM') + }) + }, + async (args: { prompt: string }, ctx): Promise => { + try { + // Request sampling from client + const result = (await ctx.mcpReq.send({ + method: 'sampling/createMessage', + params: { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: args.prompt + } + } + ], + maxTokens: 100 + } + })) as { content?: { text?: string }; message?: { content?: { text?: string } } }; + + const modelResponse = result.content?.text || result.message?.content?.text || 'No response'; + + return { + content: [ + { + type: 'text', + text: `LLM response: ${modelResponse}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Sampling not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // Elicitation tool - requests user input from client + mcpServer.registerTool( + 'test_elicitation', + { + description: 'Tests server-initiated elicitation (user input request)', + inputSchema: z.object({ + message: z.string().describe('The message to show the user') + }) + }, + async (args: { message: string }, ctx): Promise => { + try { + // Request user input from client + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: args.message, + requestedSchema: { + type: 'object', + properties: { + response: { + type: 'string', + description: "User's response" + } + }, + required: ['response'] + } + } + }); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `User response: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1034: Elicitation with default values for all primitive types + mcpServer.registerTool( + 'test_elicitation_sep1034_defaults', + { + description: 'Tests elicitation with default values per SEP-1034', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + try { + // Request user input with default values for all primitive types + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: 'Please review and update the form fields with defaults', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + description: 'User name', + default: 'John Doe' + }, + age: { + type: 'integer', + description: 'User age', + default: 30 + }, + score: { + type: 'number', + description: 'User score', + default: 95.5 + }, + status: { + type: 'string', + description: 'User status', + enum: ['active', 'inactive', 'pending'], + default: 'active' + }, + verified: { + type: 'boolean', + description: 'Verification status', + default: true + } + }, + required: [] + } + } + }); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1330: Elicitation with enum schema improvements + mcpServer.registerTool( + 'test_elicitation_sep1330_enums', + { + description: 'Tests elicitation with enum schema improvements per SEP-1330', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + try { + // Request user input with all 5 enum schema variants + const result = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + message: 'Please select options from the enum fields', + requestedSchema: { + type: 'object', + properties: { + // Untitled single-select enum (basic) + untitledSingle: { + type: 'string', + description: 'Select one option', + enum: ['option1', 'option2', 'option3'] + }, + // Titled single-select enum (using oneOf with const/title) + titledSingle: { + type: 'string', + description: 'Select one option with titles', + oneOf: [ + { const: 'value1', title: 'First Option' }, + { const: 'value2', title: 'Second Option' }, + { const: 'value3', title: 'Third Option' } + ] + }, + // Legacy titled enum (using enumNames - deprecated) + legacyEnum: { + type: 'string', + description: 'Select one option (legacy)', + enum: ['opt1', 'opt2', 'opt3'], + enumNames: ['Option One', 'Option Two', 'Option Three'] + }, + // Untitled multi-select enum + untitledMulti: { + type: 'array', + description: 'Select multiple options', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['option1', 'option2', 'option3'] + } + }, + // Titled multi-select enum (using anyOf with const/title) + titledMulti: { + type: 'array', + description: 'Select multiple options with titles', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'value1', title: 'First Choice' }, + { const: 'value2', title: 'Second Choice' }, + { const: 'value3', title: 'Third Choice' } + ] + } + } + }, + required: [] + } + } + }); + + const elicitResult = result as { action?: string; content?: unknown }; + return { + content: [ + { + type: 'text', + text: `Elicitation completed: action=${elicitResult.action}, content=${JSON.stringify(elicitResult.content || {})}` + } + ] + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Elicitation not supported or error: ${error instanceof Error ? error.message : String(error)}` + } + ] + }; + } + } + ); + + // SEP-1613: JSON Schema 2020-12 conformance test tool + mcpServer.registerTool( + 'json_schema_2020_12_tool', + { + description: 'Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)', + inputSchema: z.object({ + name: z.string().optional(), + address: z + .object({ + street: z.string().optional(), + city: z.string().optional() + }) + .optional() + }) + }, + async (args: { name?: string; address?: { street?: string; city?: string } }): Promise => { + return { + content: [ + { + type: 'text', + text: `JSON Schema 2020-12 tool called with: ${JSON.stringify(args)}` + } + ] + }; + } + ); + + // ===== RESOURCES ===== + + // Static text resource + mcpServer.registerResource( + 'static-text', + 'test://static-text', + { + title: 'Static Text Resource', + description: 'A static text resource for testing', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'This is the content of the static text resource.' + } + ] + }; + } + ); + + // Static binary resource + mcpServer.registerResource( + 'static-binary', + 'test://static-binary', + { + title: 'Static Binary Resource', + description: 'A static binary resource (image) for testing', + mimeType: 'image/png' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://static-binary', + mimeType: 'image/png', + blob: TEST_IMAGE_BASE64 + } + ] + }; + } + ); + + // Resource template + mcpServer.registerResource( + 'template', + new ResourceTemplate('test://template/{id}/data', { list: undefined }), + { + title: 'Resource Template', + description: 'A resource template with parameter substitution', + mimeType: 'application/json' + }, + async (uri, variables): Promise => { + const id = variables.id; + return { + contents: [ + { + uri: uri.toString(), + mimeType: 'application/json', + text: JSON.stringify({ + id, + templateTest: true, + data: `Data for ID: ${id}` + }) + } + ] + }; + } + ); + + // Watched resource + mcpServer.registerResource( + 'watched-resource', + 'test://watched-resource', + { + title: 'Watched Resource', + description: 'A resource that auto-updates every 3 seconds', + mimeType: 'text/plain' + }, + async (): Promise => { + return { + contents: [ + { + uri: 'test://watched-resource', + mimeType: 'text/plain', + text: watchedResourceContent + } + ] + }; + } + ); + + // Subscribe/Unsubscribe handlers + mcpServer.server.setRequestHandler('resources/subscribe', async request => { + const uri = request.params.uri; + resourceSubscriptions.add(uri); + sendLog('info', `Subscribed to resource: ${uri}`); + return {}; + }); + + mcpServer.server.setRequestHandler('resources/unsubscribe', async request => { + const uri = request.params.uri; + resourceSubscriptions.delete(uri); + sendLog('info', `Unsubscribed from resource: ${uri}`); + return {}; + }); + + // ===== PROMPTS ===== + + // Simple prompt + mcpServer.registerPrompt( + 'test_simple_prompt', + { + title: 'Simple Test Prompt', + description: 'A simple prompt without arguments' + }, + async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' + } + } + ] + }; + } + ); + + // Prompt with arguments + mcpServer.registerPrompt( + 'test_prompt_with_arguments', + { + title: 'Prompt With Arguments', + description: 'A prompt with required arguments', + argsSchema: z.object({ + arg1: z.string().describe('First test argument'), + arg2: z.string().describe('Second test argument') + }) + }, + async (args: { arg1: string; arg2: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Prompt with arguments: arg1='${args.arg1}', arg2='${args.arg2}'` + } + } + ] + }; + } + ); + + // Prompt with embedded resource + mcpServer.registerPrompt( + 'test_prompt_with_embedded_resource', + { + title: 'Prompt With Embedded Resource', + description: 'A prompt that includes an embedded resource', + argsSchema: z.object({ + resourceUri: z.string().describe('URI of the resource to embed') + }) + }, + async (args: { resourceUri: string }): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'resource', + resource: { + uri: args.resourceUri, + mimeType: 'text/plain', + text: 'Embedded resource content for testing.' + } + } + }, + { + role: 'user', + content: { + type: 'text', + text: 'Please process the embedded resource above.' + } + } + ] + }; + } + ); + + // Prompt with image + mcpServer.registerPrompt( + 'test_prompt_with_image', + { + title: 'Prompt With Image', + description: 'A prompt that includes image content' + }, + async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'image', + data: TEST_IMAGE_BASE64, + mimeType: 'image/png' + } + }, + { + role: 'user', + content: { type: 'text', text: 'Please analyze the image above.' } + } + ] + }; + } + ); + + // ===== LOGGING ===== + + mcpServer.server.setRequestHandler('logging/setLevel', async request => { + const level = request.params.level; + sendLog('info', `Log level set to: ${level}`); + return {}; + }); + + // ===== COMPLETION ===== + + mcpServer.server.setRequestHandler('completion/complete', async () => { + // Basic completion support - returns empty array for conformance + // Real implementations would provide contextual suggestions + return { + completion: { + values: [], + total: 0, + hasMore: false + } + }; + }); + + return mcpServer; +} + +// ===== EXPRESS APP ===== + +const app = express(); +app.use(express.json()); + +// DNS rebinding protection: reject non-localhost Host headers +app.use(localhostHostValidation()); + +// Configure CORS to expose Mcp-Session-Id header for browser-based clients +app.use( + cors({ + origin: '*', + exposedHeaders: ['Mcp-Session-Id'], + allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id'] + }) +); + +// Handle POST requests - stateful mode +app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: NodeStreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport for established sessions + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // Create new transport for initialization requests + const mcpServer = createMcpServer(); + + transport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000, // 5 second retry interval for SEP-1699 + onsessioninitialized: (newSessionId: string) => { + transports[newSessionId] = transport; + servers[newSessionId] = mcpServer; + console.log(`Session initialized with ID: ${newSessionId}`); + } + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + delete transports[sid]; + if (servers[sid]) { + servers[sid].close(); + delete servers[sid]; + } + console.log(`Session ${sid} closed`); + } + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else if (sessionId) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32_001, message: 'Session not found' }, + id: null + }); + return; + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Session ID required' }, + id: null + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32_603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +// Handle GET requests - SSE streams for sessions +app.get('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`); + } else { + console.log(`Establishing SSE stream for session ${sessionId}`); + } + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Handle DELETE requests - session termination +app.delete('/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (!sessionId) { + res.status(400).send('Missing session ID'); + return; + } + if (!transports[sessionId]) { + res.status(404).send('Session not found'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + +// Start server +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`); + console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); +}); diff --git a/test/conformance/src/helpers/conformanceOAuthProvider.ts b/test/conformance/src/helpers/conformanceOAuthProvider.ts new file mode 100644 index 0000000..1643afa --- /dev/null +++ b/test/conformance/src/helpers/conformanceOAuthProvider.ts @@ -0,0 +1,93 @@ +import type { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthClientProvider, + OAuthTokens +} from '@modelcontextprotocol/client'; + +export class ConformanceOAuthProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _authCode?: string; + private _authCodePromise?: Promise; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _clientMetadataUrl?: string | URL + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + get clientMetadataUrl(): string | undefined { + return this._clientMetadataUrl?.toString(); + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + try { + const response = await fetch(authorizationUrl.toString(), { + redirect: 'manual' // Don't follow redirects automatically + }); + + // Get the Location header which contains the redirect with auth code + const location = response.headers.get('location'); + if (location) { + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get('code'); + if (code) { + this._authCode = code; + return; + } else { + throw new Error('No auth code in redirect URL'); + } + } else { + throw new Error(`No redirect location received, from '${authorizationUrl.toString()}'`); + } + } catch (error) { + console.error('Failed to fetch authorization URL:', error); + throw error; + } + } + + async getAuthCode(): Promise { + if (this._authCode) { + return this._authCode; + } + throw new Error('No authorization code'); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } +} diff --git a/test/conformance/src/helpers/logger.ts b/test/conformance/src/helpers/logger.ts new file mode 100644 index 0000000..8de9342 --- /dev/null +++ b/test/conformance/src/helpers/logger.ts @@ -0,0 +1,27 @@ +/** + * Simple logger with configurable log levels. + * Set to 'error' in tests to suppress debug output. + */ + +export type LogLevel = 'debug' | 'error'; + +let currentLogLevel: LogLevel = 'debug'; + +export function setLogLevel(level: LogLevel): void { + currentLogLevel = level; +} + +export function getLogLevel(): LogLevel { + return currentLogLevel; +} + +export const logger = { + debug: (...args: unknown[]): void => { + if (currentLogLevel === 'debug') { + console.log(...args); + } + }, + error: (...args: unknown[]): void => { + console.error(...args); + } +}; diff --git a/test/conformance/src/helpers/withOAuthRetry.ts b/test/conformance/src/helpers/withOAuthRetry.ts new file mode 100644 index 0000000..cbed3e2 --- /dev/null +++ b/test/conformance/src/helpers/withOAuthRetry.ts @@ -0,0 +1,104 @@ +import type { FetchLike, Middleware } from '@modelcontextprotocol/client'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from '@modelcontextprotocol/client'; + +import { ConformanceOAuthProvider } from './conformanceOAuthProvider.js'; + +export const handle401 = async ( + response: Response, + provider: ConformanceOAuthProvider, + next: FetchLike, + serverUrl: string | URL +): Promise => { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: next + }); + + if (result === 'REDIRECT') { + // Ordinarily, we'd wait for the callback to be handled here, + // but in our conformance provider, we get the authorization code + // during the redirect handling, so we can go straight to + // retrying the auth step. + // await provider.waitForCallback(); + + const authorizationCode = await provider.getAuthCode(); + + // TODO: this retry logic should be incorporated into the typescript SDK + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: next + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(`Authentication failed with result: ${result}`); + } + } +}; +/** + * Creates a fetch wrapper that handles OAuth authentication with retry logic. + * + * Unlike the SDK's withOAuth, this version: + * - Automatically handles authorization redirects by retrying with fresh tokens + * - Does not throw UnauthorizedError on redirect, but instead retries + * - Calls next() instead of throwing for redirect-based auth + * + * @param provider - OAuth client provider for authentication + * @param baseUrl - Base URL for OAuth server discovery (defaults to request URL domain) + * @returns A fetch middleware function + */ +export const withOAuthRetry = ( + clientName: string, + baseUrl?: string | URL, + handle401Fn: typeof handle401 = handle401, + clientMetadataUrl?: string, + existingProvider?: ConformanceOAuthProvider +): Middleware => { + const provider = + existingProvider ?? + new ConformanceOAuthProvider( + 'http://localhost:3000/callback', + { + client_name: clientName, + redirect_uris: ['http://localhost:3000/callback'] + }, + clientMetadataUrl + ); + return (next: FetchLike) => { + return async (input: string | URL, init?: RequestInit): Promise => { + const makeRequest = async (): Promise => { + const headers = new Headers(init?.headers); + + // Add authorization header if tokens are available + const tokens = await provider.tokens(); + if (tokens) { + headers.set('Authorization', `Bearer ${tokens.access_token}`); + } + + return await next(input, { ...init, headers }); + }; + + let response = await makeRequest(); + + // Handle 401 responses by attempting re-authentication + if (response.status === 401 || response.status === 403) { + const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); + await handle401Fn(response, provider, next, serverUrl); + + response = await makeRequest(); + } + + // If we still have a 401 after re-auth attempt, throw an error + if (response.status === 401 || response.status === 403) { + const url = typeof input === 'string' ? input : input.toString(); + throw new UnauthorizedError(`Authentication failed for ${url}`); + } + + return response; + }; + }; +}; diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json new file mode 100644 index 0000000..f529719 --- /dev/null +++ b/test/conformance/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] + } + } +} diff --git a/test/conformance/vitest.config.js b/test/conformance/vitest.config.js new file mode 100644 index 0000000..38f030f --- /dev/null +++ b/test/conformance/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '../../common/vitest-config/vitest.config.js'; + +export default baseConfig; diff --git a/test/helpers/eslint.config.mjs b/test/helpers/eslint.config.mjs new file mode 100644 index 0000000..951c9f3 --- /dev/null +++ b/test/helpers/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; diff --git a/test/helpers/package.json b/test/helpers/package.json new file mode 100644 index 0000000..88f2c3f --- /dev/null +++ b/test/helpers/package.json @@ -0,0 +1,40 @@ +{ + "name": "@modelcontextprotocol/test-helpers", + "private": true, + "version": "2.0.0-alpha.0", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10.24.0" + }, + "packageManager": "pnpm@10.24.0", + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "start": "npm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client" + }, + "devDependencies": { + "@modelcontextprotocol/core": "workspace:^", + "zod": "catalog:runtimeShared", + "vitest": "catalog:devTools", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^" + } +} diff --git a/test/helpers/src/helpers/http.ts b/test/helpers/src/helpers/http.ts new file mode 100644 index 0000000..86252e1 --- /dev/null +++ b/test/helpers/src/helpers/http.ts @@ -0,0 +1,96 @@ +import type { Server, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import type { Response } from 'express'; +import { vi } from 'vitest'; + +/** + * Attach a listener to an existing server on a random localhost port and return its base URL. + */ +export async function listenOnRandomPort(server: Server, host: string = '127.0.0.1'): Promise { + return new Promise(resolve => { + server.listen(0, host, () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://${host}:${addr.port}`)); + }); + }); +} + +// ========================= +// HTTP/Express mock helpers +// ========================= + +/** + * Create a minimal Express-like Response mock for tests. + * + * The mock supports: + * - redirect() + * - status().json().send() chaining + * - set()/header() + * - optional getRedirectUrl() helper used in some tests + */ +export function createExpressResponseMock(options: { trackRedirectUrl?: boolean } = {}): Response & { + getRedirectUrl?: () => string; +} { + let capturedRedirectUrl: string | undefined; + + const res: Partial & { getRedirectUrl?: () => string } = { + redirect: vi.fn((urlOrStatus: string | number, maybeUrl?: string | number) => { + if (options.trackRedirectUrl) { + if (typeof urlOrStatus === 'string') { + capturedRedirectUrl = urlOrStatus; + } else if (typeof maybeUrl === 'string') { + capturedRedirectUrl = maybeUrl; + } + } + return res as Response; + }) as unknown as Response['redirect'], + status: vi.fn().mockImplementation((_code: number) => { + // status code is ignored for now; tests assert it via jest/vitest spies + return res as Response; + }), + json: vi.fn().mockImplementation((_body: unknown) => { + // body is ignored; tests usually assert via spy + return res as Response; + }), + send: vi.fn().mockImplementation((_body?: unknown) => { + // body is ignored; tests usually assert via spy + return res as Response; + }), + set: vi.fn().mockImplementation((_field: string, _value?: string | string[]) => { + // header value is ignored in the generic mock; tests spy on set() + return res as Response; + }), + header: vi.fn().mockImplementation((_field: string, _value?: string | string[]) => { + return res as Response; + }) + }; + + if (options.trackRedirectUrl) { + res.getRedirectUrl = () => { + if (capturedRedirectUrl === undefined) { + throw new Error('No redirect URL was captured. Ensure redirect() was called first.'); + } + return capturedRedirectUrl; + }; + } + + return res as Response & { getRedirectUrl?: () => string }; +} + +/** + * Create a Node http.ServerResponse mock used for low-level transport tests. + * + * All core methods are jest/vitest fns returning `this` so that + * tests can assert on writeHead/write/on/end calls. + */ +export function createNodeServerResponseMock(): ServerResponse { + const res = { + writeHead: vi.fn().mockReturnThis(), + write: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis() + }; + + return res as unknown as ServerResponse; +} diff --git a/test/helpers/src/helpers/oauth.ts b/test/helpers/src/helpers/oauth.ts new file mode 100644 index 0000000..61c3d44 --- /dev/null +++ b/test/helpers/src/helpers/oauth.ts @@ -0,0 +1,89 @@ +import type { FetchLike } from '@modelcontextprotocol/core'; +import { vi } from 'vitest'; + +export interface MockOAuthFetchOptions { + resourceServerUrl: string; + authServerUrl: string; + /** + * Optional hook to inspect or override the token request. + */ + onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise; +} + +/** + * Shared mock fetch implementation for OAuth flows used in client tests. + * + * It handles: + * - OAuth Protected Resource Metadata discovery + * - Authorization Server Metadata discovery + * - Token endpoint responses + */ +export function createMockOAuthFetch(options: MockOAuthFetchOptions): FetchLike { + const { resourceServerUrl, authServerUrl, onTokenRequest } = options; + + return async (input: string | URL, init?: RequestInit): Promise => { + const url = input instanceof URL ? input : new URL(input); + + // Protected resource metadata discovery + if (url.origin === resourceServerUrl.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') { + return Response.json( + { + resource: resourceServerUrl, + authorization_servers: [authServerUrl] + }, + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Authorization server metadata discovery + if (url.origin === authServerUrl && url.pathname === '/.well-known/oauth-authorization-server') { + return Response.json( + { + issuer: authServerUrl, + authorization_endpoint: `${authServerUrl}/authorize`, + token_endpoint: `${authServerUrl}/token`, + response_types_supported: ['code'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt'] + }, + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Token endpoint + if (url.origin === authServerUrl && url.pathname === '/token') { + if (onTokenRequest) { + await onTokenRequest(url, init); + } + + return Response.json( + { + access_token: 'test-access-token', + token_type: 'Bearer' + }, + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + throw new Error(`Unexpected URL in mock OAuth fetch: ${url.toString()}`); + }; +} + +type MockFetch = (...args: unknown[]) => unknown; + +/** + * Helper to install a vi.fn-based global.fetch mock for tests that rely on global fetch. + */ +export function mockGlobalFetch(): MockFetch { + const mockFetch = vi.fn() as unknown as MockFetch; + (globalThis as { fetch?: MockFetch }).fetch = mockFetch; + return mockFetch; +} diff --git a/test/helpers/src/helpers/tasks.ts b/test/helpers/src/helpers/tasks.ts new file mode 100644 index 0000000..4db3231 --- /dev/null +++ b/test/helpers/src/helpers/tasks.ts @@ -0,0 +1,33 @@ +import type { Task } from '@modelcontextprotocol/core'; + +/** + * Polls the provided getTask function until the task reaches the desired status or times out. + */ +export async function waitForTaskStatus( + getTask: (taskId: string) => Promise, + taskId: string, + desiredStatus: Task['status'], + { + intervalMs = 100, + timeoutMs = 10_000 + }: { + intervalMs?: number; + timeoutMs?: number; + } = {} +): Promise { + const start = Date.now(); + + // eslint-disable-next-line no-constant-condition + while (true) { + const task = await getTask(taskId); + if (task && task.status === desiredStatus) { + return task; + } + + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for task ${taskId} to reach status ${desiredStatus}`); + } + + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} diff --git a/test/helpers/src/index.ts b/test/helpers/src/index.ts new file mode 100644 index 0000000..1ecfa8e --- /dev/null +++ b/test/helpers/src/index.ts @@ -0,0 +1,3 @@ +export * from './helpers/http.js'; +export * from './helpers/oauth.js'; +export * from './helpers/tasks.js'; diff --git a/test/helpers/tsconfig.json b/test/helpers/tsconfig.json new file mode 100644 index 0000000..ce44773 --- /dev/null +++ b/test/helpers/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/test/helpers/vitest.config.js b/test/helpers/vitest.config.js new file mode 100644 index 0000000..38f030f --- /dev/null +++ b/test/helpers/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '../../common/vitest-config/vitest.config.js'; + +export default baseConfig; diff --git a/test/integration/CHANGELOG.md b/test/integration/CHANGELOG.md new file mode 100644 index 0000000..7672abd --- /dev/null +++ b/test/integration/CHANGELOG.md @@ -0,0 +1,11 @@ +# @modelcontextprotocol/test-integration + +## 2.0.0-alpha.1 + +### Patch Changes + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - remove deprecated .tool, + .prompt, .resource method signatures + +- [#1419](https://github.com/modelcontextprotocol/typescript-sdk/pull/1419) [`dcf708d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcf708d892b7ca5f137c74109d42cdeb05e2ee3a) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - deprecated .tool, .prompt, + .resource method removal diff --git a/test/integration/eslint.config.mjs b/test/integration/eslint.config.mjs new file mode 100644 index 0000000..951c9f3 --- /dev/null +++ b/test/integration/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; diff --git a/test/integration/package.json b/test/integration/package.json new file mode 100644 index 0000000..8618d05 --- /dev/null +++ b/test/integration/package.json @@ -0,0 +1,55 @@ +{ + "name": "@modelcontextprotocol/test-integration", + "private": true, + "version": "2.0.0-alpha.1", + "description": "Model Context Protocol implementation for TypeScript", + "license": "MIT", + "author": "Anthropic, PBC (https://anthropic.com)", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" + }, + "engines": { + "node": ">=20", + "pnpm": ">=10.24.0" + }, + "packageManager": "pnpm@10.24.0", + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "lint": "eslint test/ && prettier --ignore-path ../../.prettierignore --check .", + "lint:fix": "eslint test/ --fix && prettier --ignore-path ../../.prettierignore --write .", + "check": "npm run typecheck && npm run lint", + "test": "vitest run", + "test:watch": "vitest", + "start": "npm run server", + "server": "tsx watch --clear-screen=false scripts/cli.ts server", + "client": "tsx scripts/cli.ts client", + "test:integration:bun": "bun test test/server/bun.test.ts", + "test:integration:deno": "deno test --no-check --allow-net --allow-read --allow-env test/server/deno.test.ts" + }, + "devDependencies": { + "@cfworker/json-schema": "catalog:runtimeShared", + "@modelcontextprotocol/client": "workspace:^", + "@modelcontextprotocol/core": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/express": "workspace:^", + "@modelcontextprotocol/node": "workspace:^", + "@modelcontextprotocol/server": "workspace:^", + "@modelcontextprotocol/test-helpers": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@valibot/to-json-schema": "catalog:devTools", + "arktype": "catalog:devTools", + "supertest": "catalog:devTools", + "valibot": "catalog:devTools", + "vitest": "catalog:devTools", + "wrangler": "catalog:devTools", + "zod": "catalog:runtimeShared" + } +} diff --git a/test/integration/test/__fixtures__/serverThatHangs.ts b/test/integration/test/__fixtures__/serverThatHangs.ts new file mode 100644 index 0000000..dbaf198 --- /dev/null +++ b/test/integration/test/__fixtures__/serverThatHangs.ts @@ -0,0 +1,43 @@ +import process from 'node:process'; +import { setInterval } from 'node:timers'; + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +const transport = new StdioServerTransport(); + +const server = new McpServer( + { + name: 'server-that-hangs', + title: 'Test Server that hangs', + version: '1.0.0' + }, + { + capabilities: { + logging: {} + } + } +); + +await server.connect(transport); + +// Keep process alive even after stdin closes +const keepAlive = setInterval(() => {}, 60_000); + +// Prevent transport close from exiting +transport.onclose = () => { + // Intentionally ignore - we want to test the signal handling +}; + +const doNotExitImmediately = async (signal: NodeJS.Signals) => { + await server.sendLoggingMessage({ + level: 'debug', + data: `received signal ${signal}` + }); + // Clear keepalive but delay exit to simulate slow shutdown + clearInterval(keepAlive); + setInterval(() => {}, 30_000); +}; + +process.on('SIGINT', doNotExitImmediately); +process.on('SIGTERM', doNotExitImmediately); diff --git a/test/integration/test/__fixtures__/testServer.ts b/test/integration/test/__fixtures__/testServer.ts new file mode 100644 index 0000000..407c1c9 --- /dev/null +++ b/test/integration/test/__fixtures__/testServer.ts @@ -0,0 +1,20 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: 'test-server', + version: '1.0.0' +}); + +await server.connect(transport); + +const exit = async () => { + await server.close(); + // eslint-disable-next-line unicorn/no-process-exit + process.exit(0); +}; + +process.on('SIGINT', exit); +process.on('SIGTERM', exit); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts new file mode 100644 index 0000000..52d151b --- /dev/null +++ b/test/integration/test/client/client.test.ts @@ -0,0 +1,4252 @@ +import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/client'; +import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + ElicitResultSchema, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + ProtocolErrorCode, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +/*** + * Test: Initialize with Matching Protocol Version + */ +test('should initialize with matching protocol version', async () => { + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + }, + instructions: 'test instructions' + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await client.connect(clientTransport); + + // Should have sent initialize with latest version + expect(clientTransport.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'initialize', + params: expect.objectContaining({ + protocolVersion: LATEST_PROTOCOL_VERSION + }) + }), + expect.objectContaining({ + relatedRequestId: undefined + }) + ); + + // Should have the instructions returned + expect(client.getInstructions()).toEqual('test instructions'); +}); + +/*** + * Test: Initialize with Supported Older Protocol Version + */ +test('should initialize with supported older protocol version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: OLD_VERSION, + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await client.connect(clientTransport); + + // Connection should succeed with the older version + expect(client.getServerVersion()).toEqual({ + name: 'test', + version: '1.0' + }); + + // Expect no instructions + expect(client.getInstructions()).toBeUndefined(); +}); + +/*** + * Test: Reconnecting with the same Client restores protocol version on new transport + */ +test('should restore negotiated protocol version on transport when reconnecting with same client', async () => { + const setProtocolVersion = vi.fn(); + const initialTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setProtocolVersion, + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + initialTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { name: 'test', version: '1.0' } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client({ name: 'test client', version: '1.0' }); + await client.connect(initialTransport); + + // Initial handshake should have set the protocol version on the transport + expect(setProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + // Now simulate reconnection: new transport with a pre-existing sessionId. + // connect() will early-return without re-initializing, but MUST restore the protocol version + // so HTTP transports can keep sending the required mcp-protocol-version header. + const reconnectSetProtocolVersion = vi.fn(); + const reconnectTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + setProtocolVersion: reconnectSetProtocolVersion, + send: vi.fn().mockResolvedValue(undefined), + sessionId: 'existing-session-id' + }; + + await client.connect(reconnectTransport); + + // No initialize request should have been sent (sessionId was set) + expect(reconnectTransport.send).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'initialize' }), expect.anything()); + // But the protocol version MUST have been restored onto the new transport + expect(reconnectSetProtocolVersion).toHaveBeenCalledWith(LATEST_PROTOCOL_VERSION); +}); + +/*** + * Test: Reject Unsupported Protocol Version + */ +test('should reject unsupported protocol version', async () => { + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: 'invalid-version', + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: invalid-version"); + + expect(clientTransport.close).toHaveBeenCalled(); +}); + +/*** + * Test: Connect New Client to Old Supported Server Version + */ +test('should connect new client to old, supported server version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler('initialize', _request => ({ + protocolVersion: OLD_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'old server', + version: '1.0' + } + })); + + server.setRequestHandler('resources/list', () => ({ + resources: [] + })); + + server.setRequestHandler('tools/list', () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'new client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(client.getServerVersion()).toEqual({ + name: 'old server', + version: '1.0' + }); +}); + +/*** + * Test: Version Negotiation with Old Client and Newer Server + */ +test('should negotiate version when client is old, and newer server supports its version', async () => { + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler('initialize', _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler('resources/list', () => ({ + resources: [] + })); + + server.setRequestHandler('tools/list', () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'old client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(client.getServerVersion()).toEqual({ + name: 'new server', + version: '1.0' + }); +}); + +/*** + * Test: Throw when Old Client and Server Version Mismatch + */ +test("should throw when client is old, and server doesn't support its version", async () => { + const FUTURE_VERSION = 'FUTURE_VERSION'; + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler('initialize', _request => ({ + protocolVersion: FUTURE_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler('resources/list', () => ({ + resources: [] + })); + + server.setRequestHandler('tools/list', () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'old client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([ + expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: FUTURE_VERSION"), + server.connect(serverTransport) + ]); +}); + +/*** + * Test: Respect Server Capabilities + */ +test('should respect server capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler('initialize', _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'test', + version: '1.0' + } + })); + + server.setRequestHandler('resources/list', () => ({ + resources: [] + })); + + server.setRequestHandler('tools/list', () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server supports resources and tools, but not prompts + expect(client.getServerCapabilities()).toEqual({ + resources: {}, + tools: {} + }); + + // These should work + await expect(client.listResources()).resolves.not.toThrow(); + await expect(client.listTools()).resolves.not.toThrow(); + + // These should throw because prompts, logging, and completions are not supported + await expect(client.listPrompts()).rejects.toThrow('Server does not support prompts'); + await expect(client.setLoggingLevel('error')).rejects.toThrow('Server does not support logging'); + await expect( + client.complete({ + ref: { type: 'ref/prompt', name: 'test' }, + argument: { name: 'test', value: 'test' } + }) + ).rejects.toThrow('Server does not support completions'); +}); + +/*** + * Test: Return empty lists for missing capabilities (default behavior) + * When enforceStrictCapabilities is not set (default), list methods should + * return empty lists instead of sending requests to servers that don't + * advertise those capabilities. + */ +test('should return empty lists for missing capabilities by default', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + // Server only supports tools - no prompts or resources + tools: {} + } + } + ); + + server.setRequestHandler('initialize', _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + tools: {} + }, + serverInfo: { + name: 'test', + version: '1.0' + } + })); + + server.setRequestHandler('tools/list', () => ({ + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Client with default settings (enforceStrictCapabilities not set) + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server only supports tools + expect(client.getServerCapabilities()).toEqual({ + tools: {} + }); + + // listTools should work and return actual tools + const toolsResult = await client.listTools(); + expect(toolsResult.tools).toHaveLength(1); + expect(toolsResult.tools[0]!.name).toBe('test-tool'); + + // listPrompts should return empty list without sending request + const promptsResult = await client.listPrompts(); + expect(promptsResult.prompts).toEqual([]); + + // listResources should return empty list without sending request + const resourcesResult = await client.listResources(); + expect(resourcesResult.resources).toEqual([]); + + // listResourceTemplates should return empty list without sending request + const templatesResult = await client.listResourceTemplates(); + expect(templatesResult.resourceTemplates).toEqual([]); +}); + +/*** + * Test: Respect Client Notification Capabilities + */ +test('should respect client notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: { + listChanged: true + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // This should work because the client has the roots.listChanged capability + await expect(client.sendRootsListChanged()).resolves.not.toThrow(); + + // Create a new client without the roots.listChanged capability + const clientWithoutCapability = new Client( + { + name: 'test client without capability', + version: '1.0' + }, + { + capabilities: {}, + enforceStrictCapabilities: true + } + ); + + await clientWithoutCapability.connect(clientTransport); + + // This should throw because the client doesn't have the roots.listChanged capability + await expect(clientWithoutCapability.sendRootsListChanged()).rejects.toThrow(/^Client does not support/); +}); + +/*** + * Test: Respect Server Notification Capabilities + */ +test('should respect server notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + logging: {}, + resources: { + listChanged: true + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // These should work because the server has the corresponding capabilities + await expect(server.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow(); + await expect(server.sendResourceListChanged()).resolves.not.toThrow(); + + // This should throw because the server doesn't have the tools capability + await expect(server.sendToolListChanged()).rejects.toThrow('Server does not support notifying of tool list changes'); +}); + +/*** + * Test: Only Allow setRequestHandler for Declared Capabilities + */ +test('should only allow setRequestHandler for declared capabilities', () => { + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // This should work because sampling is a declared capability + expect(() => { + client.setRequestHandler('sampling/createMessage', () => ({ + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + })); + }).not.toThrow(); + + // This should throw because roots listing is not a declared capability + expect(() => { + client.setRequestHandler('roots/list', () => ({})); + }).toThrow('Client does not support roots capability'); +}); + +test('should allow setRequestHandler for declared elicitation capability', () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler('elicitation/create', () => ({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler('sampling/createMessage', () => ({ + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + })); + }).toThrow('Client does not support sampling capability'); +}); + +test('should accept form-mode elicitation request when client advertises empty elicitation object (back-compat)', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client handler for form-mode elicitation + client.setRequestHandler('elicitation/create', request => { + expect(request.params.mode).toBe('form'); + return { + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server should be able to send form-mode elicitation request + // This works because getSupportedElicitationModes defaults to form mode + // when neither form nor url are explicitly declared + const result = await server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + username: 'test-user', + confirmed: true + }); +}); + +test('should reject form-mode elicitation when client only supports URL mode', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const handler = vi.fn().mockResolvedValue({ + action: 'cancel' + }); + client.setRequestHandler('elicitation/create', handler); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + let resolveResponse: ((message: unknown) => void) | undefined; + const responsePromise = new Promise(resolve => { + resolveResponse = resolve; + }); + + serverTransport.onmessage = async message => { + if ('method' in message) { + if (message.method === 'initialize') { + if (!('id' in message) || message.id === undefined) { + throw new Error('Expected initialize request to include an id'); + } + const messageId = message.id; + await serverTransport.send({ + jsonrpc: '2.0', + id: messageId, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + } + }); + } else if (message.method === 'notifications/initialized') { + // ignore + } + } else { + resolveResponse?.(message); + } + }; + + await client.connect(clientTransport); + + // Server shouldn't send this, because the client capabilities + // only advertised URL mode. Test that it's rejected by the client: + const requestId = 1; + await serverTransport.send({ + jsonrpc: '2.0', + id: requestId, + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + } + }); + + const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; + + expect(response.id).toBe(requestId); + expect(response.error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(response.error.message).toContain('Client does not support form-mode elicitation requests'); + expect(handler).not.toHaveBeenCalled(); + + await client.close(); +}); + +test('should reject missing-mode elicitation when client only supports URL mode', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const handler = vi.fn().mockResolvedValue({ + action: 'cancel' + }); + client.setRequestHandler('elicitation/create', handler); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.request({ + method: 'elicitation/create', + params: { + message: 'Please provide data', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + } + }) + ).rejects.toThrow('Client does not support form-mode elicitation requests'); + + expect(handler).not.toHaveBeenCalled(); + + await Promise.all([client.close(), server.close()]); +}); + +test('should reject URL-mode elicitation when client only supports form mode', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const handler = vi.fn().mockResolvedValue({ + action: 'cancel' + }); + client.setRequestHandler('elicitation/create', handler); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + let resolveResponse: ((message: unknown) => void) | undefined; + const responsePromise = new Promise(resolve => { + resolveResponse = resolve; + }); + + serverTransport.onmessage = async message => { + if ('method' in message) { + if (message.method === 'initialize') { + if (!('id' in message) || message.id === undefined) { + throw new Error('Expected initialize request to include an id'); + } + const messageId = message.id; + await serverTransport.send({ + jsonrpc: '2.0', + id: messageId, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + } + }); + } else if (message.method === 'notifications/initialized') { + // ignore + } + } else { + resolveResponse?.(message); + } + }; + + await client.connect(clientTransport); + + // Server shouldn't send this, because the client capabilities + // only advertised form mode. Test that it's rejected by the client: + const requestId = 2; + await serverTransport.send({ + jsonrpc: '2.0', + id: requestId, + method: 'elicitation/create', + params: { + mode: 'url', + message: 'Open the authorization page', + elicitationId: 'elicitation-123', + url: 'https://example.com/authorize' + } + }); + + const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; + + expect(response.id).toBe(requestId); + expect(response.error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(response.error.message).toContain('Client does not support URL-mode elicitation requests'); + expect(handler).not.toHaveBeenCalled(); + + await client.close(); +}); + +test('should apply defaults for form-mode elicitation when applyDefaults is enabled', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + client.setRequestHandler('elicitation/create', request => { + expect(request.params.mode).toBe('form'); + return { + action: 'accept', + content: {} + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Please confirm your preferences', + requestedSchema: { + type: 'object', + properties: { + confirmed: { + type: 'boolean', + default: true + } + } + } + }); + + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ + confirmed: true + }); + + await client.close(); +}); + +/*** + * Test: Handle Client Cancelling a Request + */ +test('should handle client cancelling a request', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {} + } + } + ); + + // Set up server to delay responding to listResources + server.setRequestHandler('resources/list', async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + resources: [] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Set up abort controller + const controller = new AbortController(); + + // Issue request but cancel it immediately + const listResourcesPromise = client.listResources(undefined, { + signal: controller.signal + }); + controller.abort('Cancelled by test'); + + // Request should be rejected with an SdkError (local timeout/cancellation) + await expect(listResourcesPromise).rejects.toThrow(SdkError); +}); + +/*** + * Test: Handle Request Timeout + */ +test('should handle request timeout', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {} + } + } + ); + + // Set up server with a delayed response + server.setRequestHandler('resources/list', async (_request, ctx) => { + const timer = new Promise(resolve => { + const timeout = setTimeout(resolve, 100); + ctx.mcpReq.signal.addEventListener('abort', () => clearTimeout(timeout)); + }); + + await timer; + return { + resources: [] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Request with 0 msec timeout should fail immediately + await expect(client.listResources(undefined, { timeout: 0 })).rejects.toMatchObject({ + code: SdkErrorCode.RequestTimeout + }); +}); + +/*** + * Test: Handle Tool List Changed Notifications with Auto Refresh + */ +test('should handle tool list changed notification with auto refresh', async () => { + // List changed notifications + const notifications: [Error | null, Tool[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial tool to enable the tools capability + server.registerTool( + 'initial-tool', + { + description: 'Initial tool' + }, + async () => ({ content: [] }) + ); + + // Configure listChanged handler in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + tools: { + onChanged: (err, tools) => { + notifications.push([err, tools]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result1 = await client.listTools(); + expect(result1.tools).toHaveLength(1); + + // Register another tool - this triggers listChanged notification + server.registerTool( + 'test-tool', + { + description: 'A test tool' + }, + async () => ({ content: [] }) + ); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 2 tools because autoRefresh is true + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-tool'); +}); + +/*** + * Test: Handle Tool List Changed Notifications with Manual Refresh + */ +test('should handle tool list changed notification with manual refresh', async () => { + // List changed notifications + const notifications: [Error | null, Tool[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial tool to enable the tools capability + server.registerTool('initial-tool', {}, async () => ({ content: [] })); + + // Configure listChanged handler with manual refresh (autoRefresh: false) + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: (err, tools) => { + notifications.push([err, tools]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result1 = await client.listTools(); + expect(result1.tools).toHaveLength(1); + + // Register another tool - this triggers listChanged notification + server.registerTool( + 'test-tool', + { + description: 'A test tool' + }, + async () => ({ content: [] }) + ); + + // Wait for the notifications to be processed (no debounce) + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should be 1 notification with no tool data because autoRefresh is false + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toBeNull(); +}); + +/*** + * Test: Handle Prompt List Changed Notifications + */ +test('should handle prompt list changed notification with auto refresh', async () => { + const notifications: [Error | null, Prompt[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial prompt to enable the prompts capability + server.registerPrompt( + 'initial-prompt', + { + description: 'Initial prompt' + }, + async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + }) + ); + + // Configure listChanged handler in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + prompts: { + onChanged: (err, prompts) => { + notifications.push([err, prompts]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result1 = await client.listPrompts(); + expect(result1.prompts).toHaveLength(1); + + // Register another prompt - this triggers listChanged notification + server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + })); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 2 prompts because autoRefresh is true + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-prompt'); +}); + +/*** + * Test: Handle Resource List Changed Notifications + */ +test('should handle resource list changed notification with auto refresh', async () => { + const notifications: [Error | null, Resource[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial resource to enable the resources capability + server.registerResource('initial-resource', 'file:///initial.txt', {}, async () => ({ + contents: [{ uri: 'file:///initial.txt', text: 'Hello' }] + })); + + // Configure listChanged handler in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + resources: { + onChanged: (err, resources) => { + notifications.push([err, resources]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result1 = await client.listResources(); + expect(result1.resources).toHaveLength(1); + + // Register another resource - this triggers listChanged notification + server.registerResource('test-resource', 'file:///test.txt', {}, async () => ({ + contents: [{ uri: 'file:///test.txt', text: 'Hello' }] + })); + + // Wait for the debounced notifications to be processed + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Should be 1 notification with 2 resources because autoRefresh is true + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-resource'); +}); + +/*** + * Test: Handle Multiple List Changed Handlers + */ +test('should handle multiple list changed handlers configured together', async () => { + const toolNotifications: [Error | null, Tool[] | null][] = []; + const promptNotifications: [Error | null, Prompt[] | null][] = []; + + const server = new McpServer({ + name: 'test-server', + version: '1.0.0' + }); + + // Register initial tool and prompt to enable capabilities + server.registerTool( + 'tool-1', + { + description: 'Tool 1' + }, + async () => ({ content: [] }) + ); + server.registerPrompt( + 'prompt-1', + { + description: 'Prompt 1' + }, + async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + }) + ); + + // Configure multiple listChanged handlers in constructor + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => { + toolNotifications.push([err, tools]); + } + }, + prompts: { + debounceMs: 0, + onChanged: (err, prompts) => { + promptNotifications.push([err, prompts]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Register another tool and prompt to trigger notifications + server.registerTool( + 'tool-2', + { + description: 'Tool 2' + }, + async () => ({ content: [] }) + ); + server.registerPrompt( + 'prompt-2', + { + description: 'Prompt 2' + }, + async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }] + }) + ); + + // Wait for notifications to be processed + await new Promise(resolve => setTimeout(resolve, 100)); + + // Both handlers should have received their respective notifications + expect(toolNotifications).toHaveLength(1); + expect(toolNotifications[0]![1]).toHaveLength(2); + + expect(promptNotifications).toHaveLength(1); + expect(promptNotifications[0]![1]).toHaveLength(2); +}); + +/*** + * Test: Handler not activated when server doesn't advertise listChanged capability + */ +test('should not activate listChanged handler when server does not advertise capability', async () => { + const notifications: [Error | null, Tool[] | null][] = []; + + // Server with tools capability but WITHOUT listChanged + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, // No listChanged: true + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + })); + + // Configure listChanged handler that should NOT be activated + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => { + notifications.push([err, tools]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify server doesn't have tools.listChanged capability + expect(client.getServerCapabilities()?.tools?.listChanged).toBeFalsy(); + + // Send a tool list changed notification manually + await server.notification({ method: 'notifications/tools/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Handler should NOT have been activated because server didn't advertise listChanged + expect(notifications).toHaveLength(0); +}); + +/*** + * Test: Handler activated when server advertises listChanged capability + */ +test('should activate listChanged handler when server advertises capability', async () => { + const notifications: [Error | null, Tool[] | null][] = []; + + // Server with tools.listChanged: true capability + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [{ name: 'test-tool', inputSchema: { type: 'object' } }] + })); + + // Configure listChanged handler that SHOULD be activated + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => { + notifications.push([err, tools]); + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify server has tools.listChanged capability + expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true); + + // Send a tool list changed notification + await server.notification({ method: 'notifications/tools/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Handler SHOULD have been called + expect(notifications).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(1); +}); + +/*** + * Test: No handlers activated when server has no listChanged capabilities + */ +test('should not activate any handlers when server has no listChanged capabilities', async () => { + const toolNotifications: [Error | null, Tool[] | null][] = []; + const promptNotifications: [Error | null, Prompt[] | null][] = []; + const resourceNotifications: [Error | null, Resource[] | null][] = []; + + // Server with capabilities but NO listChanged for any + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } }); + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {}, prompts: {}, resources: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + // Configure listChanged handlers for all three types + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => toolNotifications.push([err, tools]) + }, + prompts: { + debounceMs: 0, + onChanged: (err, prompts) => promptNotifications.push([err, prompts]) + }, + resources: { + debounceMs: 0, + onChanged: (err, resources) => resourceNotifications.push([err, resources]) + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify server has no listChanged capabilities + const caps = client.getServerCapabilities(); + expect(caps?.tools?.listChanged).toBeFalsy(); + expect(caps?.prompts?.listChanged).toBeFalsy(); + expect(caps?.resources?.listChanged).toBeFalsy(); + + // Send notifications for all three types + await server.notification({ method: 'notifications/tools/list_changed' }); + await server.notification({ method: 'notifications/prompts/list_changed' }); + await server.notification({ method: 'notifications/resources/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // No handlers should have been activated + expect(toolNotifications).toHaveLength(0); + expect(promptNotifications).toHaveLength(0); + expect(resourceNotifications).toHaveLength(0); +}); + +/*** + * Test: Partial capability support - some handlers activated, others not + */ +test('should handle partial listChanged capability support', async () => { + const toolNotifications: [Error | null, Tool[] | null][] = []; + const promptNotifications: [Error | null, Prompt[] | null][] = []; + + // Server with tools.listChanged: true but prompts without listChanged + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true }, prompts: {} } }); + + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: { listChanged: true }, prompts: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }] + })); + + server.setRequestHandler('prompts/list', async () => ({ + prompts: [{ name: 'prompt-1' }] + })); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + listChanged: { + tools: { + debounceMs: 0, + onChanged: (err, tools) => toolNotifications.push([err, tools]) + }, + prompts: { + debounceMs: 0, + onChanged: (err, prompts) => promptNotifications.push([err, prompts]) + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify capability state + expect(client.getServerCapabilities()?.tools?.listChanged).toBe(true); + expect(client.getServerCapabilities()?.prompts?.listChanged).toBeFalsy(); + + // Send notifications for both + await server.notification({ method: 'notifications/tools/list_changed' }); + await server.notification({ method: 'notifications/prompts/list_changed' }); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Tools handler should have been called + expect(toolNotifications).toHaveLength(1); + // Prompts handler should NOT have been called (no prompts.listChanged) + expect(promptNotifications).toHaveLength(0); +}); + +describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ + test('should validate structuredContent against outputSchema', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'test-tool') { + return { + structuredContent: { result: 'success', count: 42 } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + }, + tasks: { + get: true, + list: {}, + result: true + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'test-tool' }); + expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); + }); + + /*** + * Test: Throw Error when structuredContent Does Not Match Schema + */ + test('should throw error when structuredContent does not match schema', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'test-tool') { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: { result: 'success', count: 'not a number' } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + }, + tasks: { + get: true, + list: {}, + result: true + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(/Structured content does not match the tool's output schema/); + }); + + /*** + * Test: Throw Error when Tool with outputSchema Returns No structuredContent + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'test-tool') { + // Return content instead of structuredContent + return { + content: [{ type: 'text', text: 'This should be structured content' }] + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + }, + tasks: { + get: true, + list: {}, + result: true + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool test-tool has an output schema but did not return structured content/ + ); + }); + + /*** + * Test: Handle Tools Without outputSchema Normally + */ + test('should handle tools without outputSchema normally', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + // No outputSchema + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'test-tool') { + // Return regular content + return { + content: [{ type: 'text', text: 'Normal response' }] + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + }, + tasks: { + get: true, + list: {}, + result: true + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should work normally without validation + const result = await client.callTool({ name: 'test-tool' }); + expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); + }); + + /*** + * Test: Handle Complex JSON Schema Validation + */ + test('should handle complex JSON schema validation', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1 + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' } + }, + required: ['created'] + } + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'complex-tool') { + return { + structuredContent: { + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z' + } + } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + }, + tasks: { + get: true, + list: {}, + result: true + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'complex-tool' }); + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + expect(structuredContent.age).toBe(30); + }); + + /*** + * Test: Fail Validation with Additional Properties When Not Allowed + */ + test('should fail validation with additional properties when not allowed', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler('initialize', async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'strict-tool') { + // Return structured content with extra property + return { + structuredContent: { + name: 'John', + extraField: 'not allowed' + } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error due to additional property + await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); +}); + +describe('Task-based execution', () => { + describe('Client calling server', () => { + let serverTaskStore: InMemoryTaskStore; + + beforeEach(() => { + serverTaskStore = new InMemoryTaskStore(); + }); + + afterEach(() => { + serverTaskStore?.cleanup(); + }); + + test('should create task on server via tool call', async () => { + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Tool executed successfully!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Client creates task on server via tool call + await client.callTool( + { name: 'test-tool', arguments: {} }, + { + task: { + ttl: 60_000 + } + } + ); + + // Verify task was created successfully by listing tasks + const taskList = await client.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThan(0); + const task = taskList.tasks[0]!; + expect(task.status).toBe('completed'); + }); + + test('should query task status from server using getTask', async () => { + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create a task + await client.callTool( + { name: 'test-tool', arguments: {} }, + { + task: { ttl: 60_000 } + } + ); + + // Query task status by listing tasks and getting the first one + const taskList = await client.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThan(0); + const task = taskList.tasks[0]!; + expect(task).toBeDefined(); + expect(task.taskId).toBeDefined(); + expect(task.status).toBe('completed'); + }); + + test('should query task result from server using getTaskResult', async () => { + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {}, + list: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Result data!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create a task using callToolStream to capture the task ID + let taskId: string | undefined; + const stream = client.experimental.tasks.callToolStream( + { name: 'test-tool', arguments: {} }, + { + task: { ttl: 60_000 } + } + ); + + for await (const message of stream) { + if (message.type === 'taskCreated') { + taskId = message.task.taskId; + } + } + + expect(taskId).toBeDefined(); + + // Query task result using the captured task ID + const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); + expect(result.content).toEqual([{ type: 'text', text: 'Result data!' }]); + }); + + test('should query task list from server using listTasks', async () => { + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create multiple tasks + const createdTaskIds: string[] = []; + + for (let i = 0; i < 2; i++) { + await client.callTool( + { name: 'test-tool', arguments: {} }, + { + task: { ttl: 60_000 } + } + ); + + // Get the task ID from the task list + const taskList = await client.experimental.tasks.listTasks(); + const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId)); + if (newTask) { + createdTaskIds.push(newTask.taskId); + } + } + + // Query task list + const taskList = await client.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); + for (const taskId of createdTaskIds) { + expect(taskList.tasks).toContainEqual( + expect.objectContaining({ + taskId, + status: 'completed' + }) + ); + } + }); + }); + + describe('Server calling client', () => { + let clientTaskStore: InMemoryTaskStore; + + beforeEach(() => { + clientTaskStore = new InMemoryTaskStore(); + }); + + afterEach(() => { + clientTaskStore?.cleanup(); + }); + + test('should create task on client via server elicitation', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server creates task on client via elicitation + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string' } + }, + required: ['username'] + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Verify task was created + const task = await server.experimental.tasks.getTask(taskId); + expect(task.status).toBe('completed'); + }); + + test('should query task status from client using getTask', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create a task on client and wait for CreateTaskResult + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide info', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Query task status + const task = await server.experimental.tasks.getTask(taskId); + expect(task).toBeDefined(); + expect(task.taskId).toBe(taskId); + expect(task.status).toBe('completed'); + }); + + test('should query task result from client using getTaskResult', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'result-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create a task on client and wait for CreateTaskResult + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide info', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Query task result using getTaskResult + const taskResult = await server.experimental.tasks.getTaskResult(taskId, ElicitResultSchema); + expect(taskResult.action).toBe('accept'); + expect(taskResult.content).toEqual({ username: 'result-user' }); + }); + + test('should query task list from client using listTasks', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create multiple tasks on client + const createdTaskIds: string[] = []; + for (let i = 0; i < 2; i++) { + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide info', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure and capture taskId + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + createdTaskIds.push(createTaskResult.task.taskId); + } + + // Query task list + const taskList = await server.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); + for (const taskId of createdTaskIds) { + expect(taskList.tasks).toContainEqual( + expect.objectContaining({ + taskId, + status: 'completed' + }) + ); + } + }); + }); + + test('should list tasks from server with pagination', async () => { + const serverTaskStore = new InMemoryTaskStore(); + + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({ + id: z.string() + }) + }, + { + async createTask({ id }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: `Result for ${id || 'unknown'}` }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create multiple tasks + const createdTaskIds: string[] = []; + + for (let i = 0; i < 3; i++) { + await client.callTool( + { name: 'test-tool', arguments: { id: `task-${i + 1}` } }, + { + task: { ttl: 60_000 } + } + ); + + // Get the task ID from the task list + const taskList = await client.experimental.tasks.listTasks(); + const newTask = taskList.tasks.find(t => !createdTaskIds.includes(t.taskId)); + if (newTask) { + createdTaskIds.push(newTask.taskId); + } + } + + // List all tasks without cursor + const firstPage = await client.experimental.tasks.listTasks(); + expect(firstPage.tasks.length).toBeGreaterThan(0); + expect(firstPage.tasks.map(t => t.taskId)).toEqual(expect.arrayContaining(createdTaskIds)); + + // If there's a cursor, test pagination + if (firstPage.nextCursor) { + const secondPage = await client.experimental.tasks.listTasks(firstPage.nextCursor); + expect(secondPage.tasks).toBeDefined(); + } + + serverTaskStore.cleanup(); + }); + + describe('Error scenarios', () => { + let serverTaskStore: InMemoryTaskStore; + let clientTaskStore: InMemoryTaskStore; + + beforeEach(() => { + serverTaskStore = new InMemoryTaskStore(); + clientTaskStore = new InMemoryTaskStore(); + }); + + afterEach(() => { + serverTaskStore?.cleanup(); + clientTaskStore?.cleanup(); + }); + + test('should throw error when querying non-existent task from server', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to get a task that doesn't exist + await expect(client.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); + }); + + test('should throw error when querying result of non-existent task from server', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to get result of a task that doesn't exist + await expect(client.experimental.tasks.getTaskResult('non-existent-task', CallToolResultSchema)).rejects.toThrow(); + }); + + test('should throw error when server queries non-existent task from client', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async () => ({ + action: 'accept', + content: { username: 'test' } + })); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to query a task that doesn't exist on client + await expect(server.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); + }); + }); +}); + +test('should respect server task capabilities', async () => { + const serverTaskStore = new InMemoryTaskStore(); + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore: serverTaskStore + } + } + } + ); + + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + enforceStrictCapabilities: true, + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server supports task creation for tools/call + expect(client.getServerCapabilities()).toEqual({ + tools: { + listChanged: true + }, + tasks: { + requests: { + tools: { + call: {} + } + } + } + }); + + // These should work because server supports tasks + await expect( + client.callTool( + { name: 'test-tool', arguments: {} }, + { + task: { ttl: 60_000 } + } + ) + ).resolves.not.toThrow(); + await expect(client.experimental.tasks.listTasks()).resolves.not.toThrow(); + + // tools/list doesn't support task creation, but it shouldn't throw - it should just ignore the task metadata + await expect( + client.request({ + method: 'tools/list', + params: {} + }) + ).resolves.not.toThrow(); + + serverTaskStore.cleanup(); +}); + +/** + * Test: requestStream() method + */ +test('should expose requestStream() method for streaming responses', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/call', async () => { + return { + content: [{ type: 'text', text: 'Tool result' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // First verify that regular request() works + const regularResult = await client.callTool({ name: 'test-tool', arguments: {} }); + expect(regularResult.content).toEqual([{ type: 'text', text: 'Tool result' }]); + + // Test requestStream with non-task request (should yield only result) + const stream = client.experimental.tasks.requestStream({ + method: 'tools/call', + params: { name: 'test-tool', arguments: {} } + }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + // Should have received only a result message (no task messages) + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Tool result' }]); + } + + await client.close(); + await server.close(); +}); + +/** + * Test: callToolStream() method + */ +test('should expose callToolStream() method for streaming tool calls', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/call', async () => { + return { + content: [{ type: 'text', text: 'Tool result' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Test callToolStream + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + // Should have received messages ending with result + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Tool result' }]); + } + + await client.close(); + await server.close(); +}); + +/** + * Test: callToolStream() with output schema validation + */ +test('should validate structured output in callToolStream()', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => { + return { + tools: [ + { + name: 'structured-tool', + description: 'A tool with output schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + value: { type: 'number' } + }, + required: ['value'] + } + } + ] + }; + }); + + server.setRequestHandler('tools/call', async () => { + return { + content: [{ type: 'text', text: 'Result' }], + structuredContent: { value: 42 } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the output schema + await client.listTools(); + + // Test callToolStream with valid structured output + const stream = client.experimental.tasks.callToolStream({ name: 'structured-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + // Should have received result with validated structured content + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.structuredContent).toEqual({ value: 42 }); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should yield error when structuredContent does not match schema', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: { result: 'success', count: 'not a number' } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('error'); + if (messages[0]!.type === 'error') { + expect(messages[0]!.error.message).toMatch(/Structured content does not match the tool's output schema/); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should yield error when tool with outputSchema returns no structuredContent', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + return { + content: [{ type: 'text', text: 'This should be structured content' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('error'); + if (messages[0]!.type === 'error') { + expect(messages[0]!.error.message).toMatch(/Tool test-tool has an output schema but did not return structured content/); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should handle tools without outputSchema normally', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + return { + content: [{ type: 'text', text: 'Normal response' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Normal response' }]); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should handle complex JSON schema validation', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1 + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' } + }, + required: ['created'] + } + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + return { + structuredContent: { + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z' + } + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'complex-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.structuredContent).toBeDefined(); + const structuredContent = messages[0]!.result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + expect(structuredContent.age).toBe(30); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should yield error with additional properties when not allowed', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + return { + structuredContent: { + name: 'John', + extraField: 'not allowed' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'strict-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('error'); + if (messages[0]!.type === 'error') { + expect(messages[0]!.error.message).toMatch(/Structured content does not match the tool's output schema/); + } + + await client.close(); + await server.close(); +}); + +test('callToolStream() should not validate structuredContent when isError is true', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + // Return isError with content (no structuredContent) - should NOT trigger validation error + return { + isError: true, + content: [{ type: 'text', text: 'Something went wrong' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + // Should have received result (not error), with isError flag set + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.isError).toBe(true); + expect(messages[0]!.result.content).toEqual([{ type: 'text', text: 'Something went wrong' }]); + } + + await client.close(); + await server.close(); +}); + +describe('getSupportedElicitationModes', () => { + test('should support nothing when capabilities are undefined', () => { + const result = getSupportedElicitationModes(undefined); + expect(result.supportsFormMode).toBe(false); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should default to form mode when capabilities are an empty object', () => { + const result = getSupportedElicitationModes({}); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should support form mode when form is explicitly declared', () => { + const result = getSupportedElicitationModes({ form: {} }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); + + test('should support url mode when url is explicitly declared', () => { + const result = getSupportedElicitationModes({ url: {} }); + expect(result.supportsFormMode).toBe(false); + expect(result.supportsUrlMode).toBe(true); + }); + + test('should support both modes when both are explicitly declared', () => { + const result = getSupportedElicitationModes({ form: {}, url: {} }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(true); + }); + + test('should support form mode when form declares applyDefaults', () => { + const result = getSupportedElicitationModes({ form: { applyDefaults: true } }); + expect(result.supportsFormMode).toBe(true); + expect(result.supportsUrlMode).toBe(false); + }); +}); + +describe('Client sampling validation with tools', () => { + test('should validate array content with tool_use when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use - should validate with CreateMessageResultWithToolsSchema + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: { arg: 'value' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + expect((result.content as Array<{ type: string }>)[0]!.type).toBe('tool_use'); + }); + + test('should validate single content when request includes tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns single content (text) - should still validate with CreateMessageResultWithToolsSchema + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'No tool needed' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should validate single content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns single content - should validate with CreateMessageResultSchema + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + expect((result.content as { type: string }).type).toBe('text'); + }); + + test('should reject array content when request has no tools', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Handler returns array content - should fail validation with CreateMessageResultSchema + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: [{ type: 'text', text: 'Array response' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }) + ).rejects.toThrow('Invalid sampling result'); + }); + + test('should validate array content when request includes toolChoice', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Handler returns array content with tool_use + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + stopReason: 'toolUse', + content: [{ type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }], + toolChoice: { mode: 'auto' } + }); + + expect(result.stopReason).toBe('toolUse'); + expect(Array.isArray(result.content)).toBe(true); + }); +}); diff --git a/test/integration/test/experimental/tasks/task.test.ts b/test/integration/test/experimental/tasks/task.test.ts new file mode 100644 index 0000000..d2aca2c --- /dev/null +++ b/test/integration/test/experimental/tasks/task.test.ts @@ -0,0 +1,144 @@ +import type { Task } from '@modelcontextprotocol/core'; +import { isTerminal, TaskCreationParamsSchema } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +describe('Task utility functions', () => { + describe('isTerminal', () => { + it('should return true for completed status', () => { + expect(isTerminal('completed')).toBe(true); + }); + + it('should return true for failed status', () => { + expect(isTerminal('failed')).toBe(true); + }); + + it('should return true for cancelled status', () => { + expect(isTerminal('cancelled')).toBe(true); + }); + + it('should return false for working status', () => { + expect(isTerminal('working')).toBe(false); + }); + + it('should return false for input_required status', () => { + expect(isTerminal('input_required')).toBe(false); + }); + }); +}); + +describe('Task Schema Validation', () => { + it('should validate task with ttl field', () => { + const createdAt = new Date().toISOString(); + const task: Task = { + taskId: 'test-123', + status: 'working', + ttl: 60_000, + createdAt, + lastUpdatedAt: createdAt, + pollInterval: 1000 + }; + + expect(task.ttl).toBe(60_000); + expect(task.createdAt).toBeDefined(); + expect(typeof task.createdAt).toBe('string'); + }); + + it('should validate task with null ttl', () => { + const createdAt = new Date().toISOString(); + const task: Task = { + taskId: 'test-456', + status: 'completed', + ttl: null, + createdAt, + lastUpdatedAt: createdAt + }; + + expect(task.ttl).toBeNull(); + }); + + it('should validate task with statusMessage field', () => { + const createdAt = new Date().toISOString(); + const task: Task = { + taskId: 'test-789', + status: 'failed', + ttl: null, + createdAt, + lastUpdatedAt: createdAt, + statusMessage: 'Operation failed due to timeout' + }; + + expect(task.statusMessage).toBe('Operation failed due to timeout'); + }); + + it('should validate task with createdAt in ISO 8601 format', () => { + const now = new Date(); + const createdAt = now.toISOString(); + const task: Task = { + taskId: 'test-iso', + status: 'working', + ttl: 30_000, + createdAt, + lastUpdatedAt: createdAt + }; + + expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + expect(new Date(task.createdAt).getTime()).toBe(now.getTime()); + }); + + it('should validate task with lastUpdatedAt in ISO 8601 format', () => { + const now = new Date(); + const createdAt = now.toISOString(); + const task: Task = { + taskId: 'test-iso', + status: 'working', + ttl: 30_000, + createdAt, + lastUpdatedAt: createdAt + }; + + expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('should validate all task statuses', () => { + const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled']; + + const createdAt = new Date().toISOString(); + for (const status of statuses) { + const task: Task = { + taskId: `test-${status}`, + status, + ttl: null, + createdAt, + lastUpdatedAt: createdAt + }; + expect(task.status).toBe(status); + } + }); +}); + +describe('TaskCreationParams Schema Validation', () => { + it('should accept ttl as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000 }); + expect(result.success).toBe(true); + }); + + it('should accept missing ttl (optional)', () => { + const result = TaskCreationParamsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('should reject null ttl (not allowed in request, only response)', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: null }); + expect(result.success).toBe(false); + }); + + it('should accept pollInterval as a number', () => { + const result = TaskCreationParamsSchema.safeParse({ pollInterval: 1000 }); + expect(result.success).toBe(true); + }); + + it('should accept both ttl and pollInterval', () => { + const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000, pollInterval: 1000 }); + expect(result.success).toBe(true); + }); +}); diff --git a/test/integration/test/experimental/tasks/taskListing.test.ts b/test/integration/test/experimental/tasks/taskListing.test.ts new file mode 100644 index 0000000..2b21e99 --- /dev/null +++ b/test/integration/test/experimental/tasks/taskListing.test.ts @@ -0,0 +1,129 @@ +import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js'; + +describe('Task Listing with Pagination', () => { + let client: Awaited>['client']; + let server: Awaited>['server']; + let taskStore: Awaited>['taskStore']; + + beforeEach(async () => { + const env = await createInMemoryTaskEnvironment(); + client = env.client; + server = env.server; + taskStore = env.taskStore; + }); + + afterEach(async () => { + taskStore.cleanup(); + await client.close(); + await server.close(); + }); + + it('should return empty list when no tasks exist', async () => { + const result = await client.experimental.tasks.listTasks(); + + expect(result.tasks).toEqual([]); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should return all tasks when less than page size', async () => { + // Create 3 tasks + for (let i = 0; i < 3; i++) { + await taskStore.createTask({}, i, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + } + + const result = await client.experimental.tasks.listTasks(); + + expect(result.tasks).toHaveLength(3); + expect(result.nextCursor).toBeUndefined(); + }); + + it('should paginate when more than page size exists', async () => { + // Create 15 tasks (page size is 10 in InMemoryTaskStore) + for (let i = 0; i < 15; i++) { + await taskStore.createTask({}, i, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + } + + // Get first page + const page1 = await client.experimental.tasks.listTasks(); + expect(page1.tasks).toHaveLength(10); + expect(page1.nextCursor).toBeDefined(); + + // Get second page using cursor + const page2 = await client.experimental.tasks.listTasks(page1.nextCursor); + expect(page2.tasks).toHaveLength(5); + expect(page2.nextCursor).toBeUndefined(); + }); + + it('should treat cursor as opaque token', async () => { + // Create 5 tasks + for (let i = 0; i < 5; i++) { + await taskStore.createTask({}, i, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + } + + // Get all tasks to get a valid cursor + const allTasks = taskStore.getAllTasks(); + const validCursor = allTasks[2]!.taskId; + + // Use the cursor - should work even though we don't know its internal structure + const result = await client.experimental.tasks.listTasks(validCursor); + expect(result.tasks).toHaveLength(2); + }); + + it('should return error code -32602 for invalid cursor', async () => { + await taskStore.createTask({}, 1, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + + // Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec + await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: ProtocolError) => { + expect(error).toBeInstanceOf(ProtocolError); + expect(error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(error.message).toContain('Invalid cursor'); + return true; + }); + }); + + it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => { + // Create a task + const task = await taskStore.createTask({}, 1, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + + // Verify it's accessible via tasks/get + const getResult = await client.experimental.tasks.getTask(task.taskId); + expect(getResult.taskId).toBe(task.taskId); + + // Verify it's also accessible via tasks/list + const listResult = await client.experimental.tasks.listTasks(); + expect(listResult.tasks).toHaveLength(1); + expect(listResult.tasks[0]!.taskId).toBe(task.taskId); + }); + + it('should not include related-task metadata in list response', async () => { + // Create a task + await taskStore.createTask({}, 1, { + method: 'tools/call', + params: { name: 'test-tool' } + }); + + const result = await client.experimental.tasks.listTasks(); + + // The response should have _meta but not include related-task metadata + expect(result._meta).toBeDefined(); + expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined(); + }); +}); diff --git a/test/integration/test/helpers/mcp.ts b/test/integration/test/helpers/mcp.ts new file mode 100644 index 0000000..1fe0b33 --- /dev/null +++ b/test/integration/test/helpers/mcp.ts @@ -0,0 +1,70 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/server'; +import { InMemoryTaskMessageQueue, InMemoryTaskStore, Server } from '@modelcontextprotocol/server'; + +export interface InMemoryTaskEnvironment { + client: Client; + server: Server; + taskStore: InMemoryTaskStore; + clientTransport: InMemoryTransport; + serverTransport: InMemoryTransport; +} + +export async function createInMemoryTaskEnvironment(options?: { + clientCapabilities?: ClientCapabilities; + serverCapabilities?: ServerCapabilities; +}): Promise { + const taskStore = new InMemoryTaskStore(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: options?.clientCapabilities ?? { + tasks: { + list: {}, + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: options?.serverCapabilities ?? { + tasks: { + list: {}, + requests: { + tools: { + call: {} + } + }, + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue() + } + } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + return { + client, + server, + taskStore, + clientTransport, + serverTransport + }; +} diff --git a/test/integration/test/issues/test1277.zod.v4.description.test.ts b/test/integration/test/issues/test1277.zod.v4.description.test.ts new file mode 100644 index 0000000..a8a8d0c --- /dev/null +++ b/test/integration/test/issues/test1277.zod.v4.description.test.ts @@ -0,0 +1,61 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1277 + * + * Zod v4 stores `.describe()` descriptions directly on the schema object, + * not in `._zod.def.description`. This test verifies that descriptions are + * correctly extracted for prompt arguments. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +describe('Issue #1277: Zod v4', () => { + test('should preserve argument descriptions from .describe()', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test', + { + argsSchema: z.object({ + name: z.string().describe('The user name'), + value: z.string().describe('The value to set') + }) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/list' + }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.arguments).toEqual([ + { name: 'name', required: true, description: 'The user name' }, + { name: 'value', required: true, description: 'The value to set' } + ]); + }); +}); diff --git a/test/integration/test/issues/test400.optional-tool-params.test.ts b/test/integration/test/issues/test400.optional-tool-params.test.ts new file mode 100644 index 0000000..b71d85b --- /dev/null +++ b/test/integration/test/issues/test400.optional-tool-params.test.ts @@ -0,0 +1,64 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/400 + * + * When a tool has all optional parameters, some LLM models call the tool without + * providing an `arguments` field. This test verifies that undefined arguments are + * handled correctly by defaulting to an empty object. + */ + +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +describe('Issue #400: Zod v4', () => { + test('should accept undefined arguments when all tool params are optional', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'optional-params-tool', + { + inputSchema: z.object({ + limit: z.number().optional(), + offset: z.number().optional() + }) + }, + async ({ limit, offset }) => ({ + content: [ + { + type: 'text', + text: `limit: ${limit ?? 'default'}, offset: ${offset ?? 'default'}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call tool without arguments (arguments is undefined) + const result = await client.request({ + method: 'tools/call', + params: { + name: 'optional-params-tool' + // arguments is intentionally omitted (undefined) + } + }); + + expect(result.isError).toBeUndefined(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'limit: default, offset: default' + } + ]); + }); +}); diff --git a/test/integration/test/issues/test_1342OauthErrorHttp200.test.ts b/test/integration/test/issues/test_1342OauthErrorHttp200.test.ts new file mode 100644 index 0000000..fd509e6 --- /dev/null +++ b/test/integration/test/issues/test_1342OauthErrorHttp200.test.ts @@ -0,0 +1,44 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1342 + * + * Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status + * instead of 4xx. Previously, the SDK would try to parse these as tokens and fail + * with a confusing Zod validation error. This test verifies that the SDK properly + * detects the error field and surfaces the actual OAuth error message. + */ + +import { exchangeAuthorization } from '@modelcontextprotocol/client'; +import { describe, expect, it, vi } from 'vitest'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('Issue #1342: OAuth error response with HTTP 200 status', () => { + const validClientInfo = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['http://localhost:3000/callback'], + token_endpoint_auth_method: 'client_secret_post' as const + }; + + it('should throw OAuth error when server returns error with HTTP 200', async () => { + // GitHub returns errors with HTTP 200 instead of 4xx + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + error: 'invalid_client', + error_description: 'The client_id and/or client_secret passed are incorrect.' + }) + }); + + await expect( + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow('The client_id and/or client_secret passed are incorrect.'); + }); +}); diff --git a/test/integration/test/processCleanup.test.ts b/test/integration/test/processCleanup.test.ts new file mode 100644 index 0000000..3554d99 --- /dev/null +++ b/test/integration/test/processCleanup.test.ts @@ -0,0 +1,114 @@ +import path from 'node:path'; +import { Readable, Writable } from 'node:stream'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { Server } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +// Use the local fixtures directory alongside this test file +const FIXTURES_DIR = path.resolve(__dirname, './__fixtures__'); + +describe('Process cleanup', () => { + vi.setConfig({ testTimeout: 15_000 }); // 15 second timeout (needs margin for CI; close() alone can take ~4s for hanging servers) + + it('server should exit cleanly after closing transport', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: {} + } + ); + + const mockReadable = new Readable({ + read() { + this.push(null); // signal EOF + } + }), + mockWritable = new Writable({ + write(chunk, encoding, callback) { + callback(); + } + }); + + // Attach mock streams to process for the server transport + const transport = new StdioServerTransport(mockReadable, mockWritable); + await server.connect(transport); + + // Close the transport + await transport.close(); + + // ensure a proper disposal mock streams + mockReadable.destroy(); + mockWritable.destroy(); + + // If we reach here without hanging, the test passes + // The test runner will fail if the process hangs + expect(true).toBe(true); + }); + + it('onclose should be called exactly once', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['--import', 'tsx', 'testServer.ts'], + cwd: FIXTURES_DIR + }); + + await client.connect(transport); + + let onCloseWasCalled = 0; + client.onclose = () => { + onCloseWasCalled++; + }; + + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onCloseWasCalled).toBe(1); + }); + + it('should exit cleanly for a server that hangs', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StdioClientTransport({ + command: 'node', + args: ['--import', 'tsx', 'serverThatHangs.ts'], + cwd: FIXTURES_DIR + }); + + await client.connect(transport); + await client.setLoggingLevel('debug'); + client.setNotificationHandler('notifications/message', notification => { + console.debug('server log: ' + notification.params.data); + }); + const serverPid = transport.pid!; + + await client.close(); + + // A short delay to allow the close event to propagate + await new Promise(resolve => setTimeout(resolve, 50)); + + try { + process.kill(serverPid, 9); + throw new Error('Expected server to be dead but it is alive'); + } catch (error: unknown) { + // 'ESRCH' the process doesn't exist + if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') { + // success + } else throw error; + } + }); +}); diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts new file mode 100644 index 0000000..825af7e --- /dev/null +++ b/test/integration/test/server.test.ts @@ -0,0 +1,3808 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Client } from '@modelcontextprotocol/client'; +import type { + CreateMessageResult, + ElicitRequestSchema, + ElicitResult, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + LoggingMessageNotification, + ResponseMessage, + Task, + Transport +} from '@modelcontextprotocol/core'; +import { + CallToolResultSchema, + ElicitResultSchema, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + SdkError, + SdkErrorCode, + SUPPORTED_PROTOCOL_VERSIONS, + toArrayAsync +} from '@modelcontextprotocol/core'; +import { createMcpExpressApp } from '@modelcontextprotocol/express'; +import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/server'; +import type { Request, Response } from 'express'; +import supertest from 'supertest'; +import * as z from 'zod/v4'; + +describe('Server with standard protocol methods', () => { + /* + Test that Server class works with standard protocol method handlers. + */ + test('should typecheck with standard protocol methods', () => { + // Create a Server with default types + const server = new Server( + { + name: 'TestServer', + version: '1.0.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + // Register handlers using method strings + server.setRequestHandler('ping', _request => { + return {}; + }); + + server.setNotificationHandler('notifications/initialized', () => { + console.log('Client initialized'); + }); + }); +}); + +describe('Server with tools capability', () => { + test('should register tools/list handler', () => { + const server = new Server( + { + name: 'ToolServer', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Register handler using method string + server.setRequestHandler('tools/list', _request => { + return { + tools: [] + }; + }); + }); +}); + +test('should accept latest protocol version', async () => { + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + }, + instructions: 'Test instructions' + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + instructions: 'Test instructions' + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with latest version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should accept supported older protocol version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: OLD_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + } + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with older version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: OLD_VERSION, + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should handle unsupported protocol version', async () => { + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + } + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with unsupported version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 'invalid-version', + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should respect client capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Implement request handler for sampling/createMessage + client.setRequestHandler('sampling/createMessage', async _request => { + // Mock implementation of createMessage + return { + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'This is a test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + + // This should work because sampling is supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).resolves.not.toThrow(); + + // This should still throw because roots are not supported by the client + await expect(server.listRoots()).rejects.toThrow(/Client does not support/); +}); + +test('should respect client elicitation capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler('elicitation/create', params => ({ + action: 'accept', + content: { + username: params.params.message.includes('username') ? 'test-user' : undefined, + confirmed: true + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).rejects.toThrow(/^Client does not support/); +}); + +test('should use elicitInput with mode: "form" by default for backwards compatibility', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler('elicitation/create', params => ({ + action: 'accept', + content: { + username: params.params.message.includes('username') ? 'test-user' : undefined, + confirmed: true + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).rejects.toThrow(/Client does not support/); +}); + +test('should throw when elicitInput is called without client form capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} // No form mode capability + } + } + } + ); + + client.setRequestHandler('elicitation/create', () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + }) + ).rejects.toThrow('Client does not support form elicitation.'); +}); + +test('should throw when elicitInput is called without client URL capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} // No URL mode capability + } + } + } + ); + + client.setRequestHandler('elicitation/create', () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Open the authorization URL', + elicitationId: 'elicitation-001', + url: 'https://example.com/auth' + }) + ).rejects.toThrow('Client does not support url elicitation.'); +}); + +test('should include form mode when sending elicitation form requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const receivedModes: string[] = []; + client.setRequestHandler('elicitation/create', request => { + receivedModes.push(request.params.mode ?? ''); + return { + action: 'accept', + content: { + confirmation: true + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Confirm action', + requestedSchema: { + type: 'object', + properties: { + confirmation: { + type: 'boolean' + } + }, + required: ['confirmation'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + confirmation: true + } + }); + + expect(receivedModes).toEqual(['form']); +}); + +test('should include url mode when sending elicitation URL requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedModes: string[] = []; + const receivedIds: string[] = []; + client.setRequestHandler('elicitation/create', request => { + receivedModes.push(request.params.mode ?? ''); + if (request.params.mode === 'url') { + receivedIds.push(request.params.elicitationId); + } + return { + action: 'decline' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Complete verification', + elicitationId: 'elicitation-xyz', + url: 'https://example.com/verify' + }) + ).resolves.toEqual({ + action: 'decline' + }); + + expect(receivedModes).toEqual(['url']); + expect(receivedIds).toEqual(['elicitation-xyz']); +}); + +test('should reject elicitInput when client response violates requested schema', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler('elicitation/create', () => ({ + action: 'accept', + + // Bad response: missing required field `username` + content: {} + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + }, + required: ['username'] + } + }) + ).rejects.toThrow('Elicitation response content does not match requested schema'); +}); + +test('should wrap unexpected validator errors during elicitInput', async () => { + class ThrowingValidator implements jsonSchemaValidator { + getValidator(_schema: JsonSchemaType): JsonSchemaValidator { + throw new Error('boom - validator exploded'); + } + } + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {}, + jsonSchemaValidator: new ThrowingValidator() + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler('elicitation/create', () => ({ + action: 'accept', + content: { + username: 'ignored' + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Provide any data', + requestedSchema: { + type: 'object', + properties: {}, + required: [] + } + }) + ).rejects.toThrow('Error validating elicitation response: boom - validator exploded'); +}); + +test('should forward notification options when using elicitation completion notifier', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + client.setNotificationHandler('notifications/elicitation/complete', () => {}); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notificationSpy = vi.spyOn(server, 'notification'); + + const notifier = server.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 }); + await notifier(); + + expect(notificationSpy).toHaveBeenCalledWith( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId: 'elicitation-789' + } + }, + expect.objectContaining({ relatedRequestId: 42 }) + ); +}); + +test('should create notifier that emits elicitation completion notification', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedIds: string[] = []; + client.setNotificationHandler('notifications/elicitation/complete', notification => { + receivedIds.push(notification.params.elicitationId); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notifier = server.createElicitationCompletionNotifier('elicitation-123'); + await notifier(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(receivedIds).toEqual(['elicitation-123']); +}); + +test('should throw when creating notifier if client lacks URL elicitation support', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow( + 'Client does not support URL elicitation (required for notifications/elicitation/complete)' + ); +}); + +test('should apply back-compat form capability injection when client sends empty elicitation object', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify that the schema preprocessing injected form capability + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({}); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); +}); + +test('should preserve form capability configuration when client enables applyDefaults', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Verify that the schema preprocessing preserved the form capability configuration + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({ applyDefaults: true }); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); +}); + +test('should validate elicitation response against requested schema', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + name: 'John Doe', + email: 'john@example.com', + age: 30 + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1 + }, + email: { + type: 'string', + minLength: 1 + }, + age: { + type: 'integer', + minimum: 0, + maximum: 150 + } + }, + required: ['name', 'email'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + name: 'John Doe', + email: 'john@example.com', + age: 30 + } + }); +}); + +test('should reject elicitation response with invalid data', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + email: '', // Invalid - too short + age: -5 // Invalid age + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Test with invalid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1 + }, + email: { + type: 'string', + minLength: 1 + }, + age: { + type: 'integer', + minimum: 0, + maximum: 150 + } + }, + required: ['name', 'email'] + } + }) + ).rejects.toThrow(/does not match requested schema/); +}); + +test('should allow elicitation reject and cancel without validation', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + let requestCount = 0; + client.setRequestHandler('elicitation/create', _request => { + requestCount++; + return requestCount === 1 ? { action: 'decline' } : { action: 'cancel' }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const schema = { + type: 'object' as const, + properties: { + name: { type: 'string' as const } + }, + required: ['name'] + }; + + // Test reject - should not validate + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your name', + requestedSchema: schema + }) + ).resolves.toEqual({ + action: 'decline' + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your name', + requestedSchema: schema + }) + ).resolves.toEqual({ + action: 'cancel' + }); +}); + +test('should respect server notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const [_clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + + // This should work because logging is supported by the server + await expect( + server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); + + // This should throw because resource notificaitons are not supported by the server + await expect(server.sendResourceUpdated({ uri: 'test://resource' })).rejects.toThrow(/^Server does not support/); +}); + +test('should only allow setRequestHandler for declared capabilities', () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {} + } + } + ); + + // These should work because the capabilities are declared + expect(() => { + server.setRequestHandler('prompts/list', () => ({ prompts: [] })); + }).not.toThrow(); + + expect(() => { + server.setRequestHandler('resources/list', () => ({ + resources: [] + })); + }).not.toThrow(); + + // These should throw because the capabilities are not declared + expect(() => { + server.setRequestHandler('tools/list', () => ({ tools: [] })); + }).toThrow(/^Server does not support tools/); + + expect(() => { + server.setRequestHandler('logging/setLevel', () => ({})); + }).toThrow(/^Server does not support logging/); +}); + +test('should handle server cancelling a request', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Set up client to delay responding to createMessage + client.setRequestHandler('sampling/createMessage', async (_request, _extra) => { + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + model: 'test', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Set up abort controller + const controller = new AbortController(); + + // Issue request but cancel it immediately + const createMessagePromise = server.createMessage( + { + messages: [], + maxTokens: 10 + }, + { + signal: controller.signal + } + ); + controller.abort('Cancelled by test'); + + // Request should be rejected with an SdkError (local timeout/cancellation) + await expect(createMessagePromise).rejects.toThrow(SdkError); +}); + +test('should handle request timeout', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + // Set up client that delays responses + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + client.setRequestHandler('sampling/createMessage', async (_request, ctx) => { + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 100); + ctx.mcpReq.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(ctx.mcpReq.signal.reason); + }); + }); + + return { + model: 'test', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Request with 0 msec timeout should fail immediately + await expect( + server.createMessage( + { + messages: [], + maxTokens: 10 + }, + { timeout: 0 } + ) + ).rejects.toMatchObject({ + code: SdkErrorCode.RequestTimeout + }); +}); + +/* + Test automatic log level handling for transports with and without sessionId + */ +test('should respect log level for transport without sessionId', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(clientTransport.sessionId).toEqual(undefined); + + // Client sets logging level to warning + await client.setLoggingLevel('warning'); + + // This one will make it through + const warningParams: LoggingMessageNotification['params'] = { + level: 'warning', + logger: 'test server', + data: 'Warning message' + }; + + // This one will not + const debugParams: LoggingMessageNotification['params'] = { + level: 'debug', + logger: 'test server', + data: 'Debug message' + }; + + // Test the one that makes it through + clientTransport.onmessage = vi.fn().mockImplementation(message => { + expect(message).toEqual({ + jsonrpc: '2.0', + method: 'notifications/message', + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams); + expect(clientTransport.onmessage).toHaveBeenCalled(); +}); + +describe('createMessage validation', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} } } // No tools capability + ); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('Client does not support sampling tools capability.'); + }); + + test('should throw when toolChoice is provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} } } // No tools capability + ); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + toolChoice: { mode: 'auto' } + }) + ).rejects.toThrow('Client does not support sampling tools capability.'); + }); + + test('should throw when tool_result is mixed with other content', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { + role: 'user', + content: [ + { type: 'tool_result', toolUseId: 'call_1', content: [] }, + { type: 'text', text: 'mixed content' } // Mixed! + ] + } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('The last message must contain only tool_result content if any is present'); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // tool_result without previous tool_use + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('tool_result blocks are not matching any tool_use from the previous message'); + }); + + test('should throw when tool_result IDs do not match tool_use IDs', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'wrong_id', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should allow text-only messages with tools (no tool_results)', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should allow valid matching tool_result/tool_use IDs', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should throw when user sends text instead of tool_result after tool_use', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // User ignores tool_use and sends text instead + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'text', text: 'actually nevermind' } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should throw when only some tool_results are provided for parallel tool_use', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Parallel tool_use but only one tool_result provided + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'call_1', name: 'tool_a', input: {} }, + { type: 'tool_use', id: 'call_2', name: 'tool_b', input: {} } + ] + }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [ + { name: 'tool_a', inputSchema: { type: 'object' } }, + { name: 'tool_b', inputSchema: { type: 'object' } } + ] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should validate tool_use/tool_result even without tools in current request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Previous request returned tool_use, now sending tool_result without tools param + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'wrong_id', content: [] } } + ], + maxTokens: 100 + // Note: no tools param - this is a follow-up request after tool execution + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should allow valid tool_use/tool_result without tools in current request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Previous request returned tool_use, now sending matching tool_result without tools param + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100 + // Note: no tools param - this is a follow-up request after tool execution + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should handle empty messages array', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Empty messages array should not crash + await expect( + server.createMessage({ + messages: [], + maxTokens: 100 + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); +}); + +describe('createMessageStream', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }); + }).toThrow('Client does not support sampling tools capability'); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(() => { + server.experimental.tasks.createMessageStream({ + messages: [ + { role: 'user', content: { type: 'text', text: 'Hello' } }, + { + role: 'user', + content: [{ type: 'tool_result', toolUseId: 'test-id', content: [{ type: 'text', text: 'result' }] }] + } + ], + maxTokens: 100 + }); + }).toThrow('tool_result blocks are not matching any tool_use from the previous message'); + }); + + describe('with tasks', () => { + let server: Server; + let client: Client; + let clientTransport: ReturnType[0]; + let serverTransport: ReturnType[1]; + + beforeEach(async () => { + server = new Server( + { name: 'test server', version: '1.0' }, + { + capabilities: { + tasks: { + taskStore: new InMemoryTaskStore() + } + } + } + ); + + client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await server.close().catch(() => {}); + await client.close().catch(() => {}); + }); + + describe('terminal message guarantees', () => { + test('should yield exactly one terminal message for successful request', async () => { + client.setRequestHandler('sampling/createMessage', async () => ({ + role: 'assistant', + content: { type: 'text', text: 'Response' }, + model: 'test-model' + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('result'); + + const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + }); + + test('should yield error as terminal message when client returns error', async () => { + client.setRequestHandler('sampling/createMessage', async () => { + throw new Error('Simulated client error'); + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 100 + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + }); + + test('should yield exactly one terminal message with result', async () => { + client.setRequestHandler('sampling/createMessage', () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + const lastMessage = messages.at(-1); + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).content).toBeDefined(); + } + }); + }); + + describe('non-task request minimality', () => { + test('should yield only result message for non-task request', async () => { + client.setRequestHandler('sampling/createMessage', () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.createMessageStream({ + messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }], + maxTokens: 100 + }); + + const messages = await toArrayAsync(stream); + + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + expect(messages.length).toBe(1); + }); + }); + + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const taskClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + sampling: {}, + tasks: { + taskStore: clientTaskStore, + requests: { + sampling: { createMessage: {} } + } + } + } + } + ); + + taskClient.setRequestHandler('sampling/createMessage', async (request, extra) => { + const result = { + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Task response' } + }; + + if (request.params.task && extra.task?.store) { + const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); + await extra.task.store.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); + + const stream = server.experimental.tasks.createMessageStream( + { + messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }], + maxTokens: 100 + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages.at(-1); + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as CreateMessageResult).model).toBe('test-model'); + } + + clientTaskStore.cleanup(); + await taskClient.close().catch(() => {}); + }); + }); + }); +}); + +describe('createMessage backwards compatibility', () => { + test('createMessage without tools returns single content (backwards compat)', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Mock client returns single text content + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Hello from LLM' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITHOUT tools + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + // Backwards compat: result.content should be single (not array) + expect(result.model).toBe('test-model'); + expect(Array.isArray(result.content)).toBe(false); + expect(result.content.type).toBe('text'); + if (result.content.type === 'text') { + expect(result.content.text).toBe('Hello from LLM'); + } + }); + + test('createMessage with tools accepts request and returns result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Mock client returns text content (tool_use schema validation is tested in types.test.ts) + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'I will use the weather tool' }, + stopReason: 'endTurn' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITH tools - verifies the overload works + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'get_weather', inputSchema: { type: 'object' } }] + }); + + // Verify result is returned correctly + expect(result.model).toBe('test-model'); + expect(result.content).toMatchObject({ type: 'text', text: 'I will use the weather tool' }); + expect(result.content).not.toBeInstanceOf(Array); + }); +}); + +test('should respect log level for transport with sessionId', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Add a session id to the transports + const SESSION_ID = 'test-session-id'; + clientTransport.sessionId = SESSION_ID; + serverTransport.sessionId = SESSION_ID; + + expect(clientTransport.sessionId).toBeDefined(); + expect(serverTransport.sessionId).toBeDefined(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Client sets logging level to warning + await client.setLoggingLevel('warning'); + + // This one will make it through + const warningParams: LoggingMessageNotification['params'] = { + level: 'warning', + logger: 'test server', + data: 'Warning message' + }; + + // This one will not + const debugParams: LoggingMessageNotification['params'] = { + level: 'debug', + logger: 'test server', + data: 'Debug message' + }; + + // Test the one that makes it through + clientTransport.onmessage = vi.fn().mockImplementation(message => { + expect(message).toEqual({ + jsonrpc: '2.0', + method: 'notifications/message', + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams, SESSION_ID); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams, SESSION_ID); + expect(clientTransport.onmessage).toHaveBeenCalled(); +}); + +describe('createMcpExpressApp', () => { + test('should create an Express app', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + }); + + test('should parse JSON bodies', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); // Disable host validation for this test + app.post('/test', (req: Request, res: Response) => { + res.json({ received: req.body }); + }); + + const response = await supertest(app).post('/test').send({ hello: 'world' }).set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: { hello: 'world' } }); + }); + + test('should reject requests with invalid Host header by default', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + jsonrpc: '2.0', + error: { + code: -32_000, + message: 'Invalid Host: evil.com' + }, + id: null + }); + }); + + test('should allow requests with localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should allow requests with 127.0.0.1 Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '127.0.0.1:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should not apply host validation when host is 0.0.0.0', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Should allow any host when bound to 0.0.0.0 + const response = await supertest(app).post('/test').set('Host', 'any-host.com:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is explicitly localhost', async () => { + const app = createMcpExpressApp({ host: 'localhost' }); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should allow requests with IPv6 localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '[::1]:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is ::1 (IPv6 localhost)', async () => { + const app = createMcpExpressApp({ host: '::1' }); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should warn when binding to 0.0.0.0', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '0.0.0.0' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('0.0.0.0')); + warnSpy.mockRestore(); + }); + + test('should warn when binding to :: (IPv6 all interfaces)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '::' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('::')); + warnSpy.mockRestore(); + }); + + test('should use custom allowedHosts when provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Should not warn when allowedHosts is provided + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + + // Should allow myapp.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'myapp.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + + // Should reject other hosts + const rejectedResponse = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + expect(rejectedResponse.status).toBe(403); + }); + + test('should override default localhost validation when allowedHosts is provided', async () => { + // Even though host is localhost, we're using custom allowedHosts + const app = createMcpExpressApp({ host: 'localhost', allowedHosts: ['custom.local'] }); + app.post('/test', (_req: Request, res: Response) => { + res.json({ success: true }); + }); + + // Should reject localhost since it's not in allowedHosts + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + expect(response.status).toBe(403); + + // Should allow custom.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'custom.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + }); +}); + +describe('Task-based execution', () => { + test('server with TaskStore should handle task-based tool execution', async () => { + const taskStore = new InMemoryTaskStore(); + + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + // Register a tool using registerToolTask + server.experimental.tasks.registerToolTask( + 'test-tool', + { + description: 'A test tool', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + // Simulate some async work + (async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + const result = { + content: [{ type: 'text', text: 'Tool executed successfully!' }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Use callToolStream to create a task and capture the task ID + let taskId: string | undefined; + const stream = client.experimental.tasks.callToolStream( + { name: 'test-tool', arguments: {} }, + { + task: { + ttl: 60_000 + } + } + ); + + for await (const message of stream) { + if (message.type === 'taskCreated') { + taskId = message.task.taskId; + } + } + + expect(taskId).toBeDefined(); + + // Wait for the task to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify we can retrieve the task + const task = await client.experimental.tasks.getTask(taskId!); + expect(task).toBeDefined(); + expect(task.status).toBe('completed'); + + // Verify we can retrieve the result + const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); + expect(result.content).toEqual([{ type: 'text', text: 'Tool executed successfully!' }]); + + // Cleanup + taskStore.cleanup(); + }); + + test('server without TaskStore should reject task-based requests', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + // No taskStore configured + } + ); + + server.setRequestHandler('tools/call', async request => { + if (request.params.name === 'test-tool') { + return { + content: [{ type: 'text', text: 'Success!' }] + }; + } + throw new Error('Unknown tool'); + }); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + })); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to get a task when server doesn't have TaskStore + // The server will return a "Method not found" error + await expect(client.experimental.tasks.getTask('non-existent')).rejects.toThrow('Method not found'); + }); + + test('should automatically attach related-task metadata to nested requests during tool execution', async () => { + const taskStore = new InMemoryTaskStore(); + + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + // Track the elicitation request to verify related-task metadata + let capturedElicitRequest: z.infer | null = null; + + // Set up client elicitation handler + client.setRequestHandler('elicitation/create', async (request, ctx) => { + let taskId: string | undefined; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const createdTask = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + taskId = createdTask.taskId; + } + + // Capture the request to verify metadata later + capturedElicitRequest = request; + + return { + action: 'accept', + content: { + username: 'test-user' + } + }; + }); + + // Register a tool using registerToolTask that makes a nested elicitation request + server.experimental.tasks.registerToolTask( + 'collect-info', + { + description: 'Collects user info via elicitation', + inputSchema: z.object({}) + }, + { + async createTask(_args, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + // Perform async work that makes a nested request + (async () => { + // During tool execution, make a nested request to the client using ctx.mcpReq.send + const elicitResult = await ctx.mcpReq.send({ + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string' } + }, + required: ['username'] + } + } + }); + + const result = { + content: [ + { + type: 'text', + text: `Collected username: ${elicitResult.action === 'accept' && elicitResult.content ? (elicitResult.content as Record).username : 'none'}` + } + ] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call tool WITH task creation using callToolStream to capture task ID + let taskId: string | undefined; + const stream = client.experimental.tasks.callToolStream( + { name: 'collect-info', arguments: {} }, + { + task: { + ttl: 60_000 + } + } + ); + + for await (const message of stream) { + if (message.type === 'taskCreated') { + taskId = message.task.taskId; + } + } + + expect(taskId).toBeDefined(); + + // Wait for completion + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify the nested elicitation request was made (related-task metadata is no longer automatically attached) + expect(capturedElicitRequest).toBeDefined(); + + // Verify tool result was correct + const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Collected username: test-user' + } + ]); + + // Cleanup + taskStore.cleanup(); + }); + + describe('Server calling client via elicitation', () => { + let clientTaskStore: InMemoryTaskStore; + + beforeEach(() => { + clientTaskStore = new InMemoryTaskStore(); + }); + + afterEach(() => { + clientTaskStore?.cleanup(); + }); + + test('should create task on client via elicitation', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'server-test-user', confirmed: true } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server creates task on client via elicitation + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string' }, + confirmed: { type: 'boolean' } + }, + required: ['username'] + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Verify task was created + const task = await server.experimental.tasks.getTask(taskId); + expect(task.status).toBe('completed'); + }); + + test('should query task from client using getTask', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { create: {} } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create task + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Provide info', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Query task + const task = await server.experimental.tasks.getTask(taskId); + expect(task).toBeDefined(); + expect(task.taskId).toBe(taskId); + expect(task.status).toBe('completed'); + }); + + test('should query task result from client using getTaskResult', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'result-user', confirmed: true } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { create: {} } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create task + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Provide info', + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string' }, + confirmed: { type: 'boolean' } + } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + // Query result + const result = await server.experimental.tasks.getTaskResult(taskId, ElicitResultSchema); + expect(result.action).toBe('accept'); + expect(result.content).toEqual({ username: 'result-user', confirmed: true }); + }); + + test('should query task list from client using listTasks', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create multiple tasks + const createdTaskIds: string[] = []; + for (let i = 0; i < 2; i++) { + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Provide info', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure and capture taskId + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + createdTaskIds.push(createTaskResult.task.taskId); + } + + // Query task list + const taskList = await server.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThanOrEqual(2); + for (const taskId of createdTaskIds) { + expect(taskList.tasks).toContainEqual( + expect.objectContaining({ + taskId, + status: 'completed' + }) + ); + } + }); + }); + + test('should handle multiple concurrent task-based tool calls', async () => { + const taskStore = new InMemoryTaskStore(); + + const server = new McpServer( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + // Register a tool using registerToolTask with variable delay + server.experimental.tasks.registerToolTask( + 'async-tool', + { + description: 'An async test tool', + inputSchema: z.object({ + delay: z.number().optional().default(10), + taskNum: z.number().optional() + }) + }, + { + async createTask({ delay, taskNum }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + + // Simulate async work + (async () => { + await new Promise(resolve => setTimeout(resolve, delay)); + const result = { + content: [{ type: 'text', text: `Completed task ${taskNum || 'unknown'}` }] + }; + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create multiple tasks concurrently + const pendingRequests = Array.from({ length: 4 }, (_, index) => + client.callTool( + { name: 'async-tool', arguments: { delay: 10 + index * 5, taskNum: index + 1 } }, + { + task: { ttl: 60_000 } + } + ) + ); + + // Wait for all tasks to complete + await Promise.all(pendingRequests); + + // Wait a bit more to ensure all tasks are completed + await new Promise(resolve => setTimeout(resolve, 50)); + + // Get all task IDs from the task list + const taskList = await client.experimental.tasks.listTasks(); + expect(taskList.tasks.length).toBeGreaterThanOrEqual(4); + const taskIds = taskList.tasks.map(t => t.taskId); + + // Verify all tasks completed successfully + for (const [i, taskId] of taskIds.entries()) { + const task = await client.experimental.tasks.getTask(taskId!); + expect(task.status).toBe('completed'); + expect(task.taskId).toBe(taskId!); + + const result = await client.experimental.tasks.getTaskResult(taskId!, CallToolResultSchema); + expect(result.content).toEqual([{ type: 'text', text: `Completed task ${i + 1}` }]); + } + + // Verify listTasks returns all tasks + const finalTaskList = await client.experimental.tasks.listTasks(); + for (const taskId of taskIds) { + expect(finalTaskList.tasks).toContainEqual(expect.objectContaining({ taskId })); + } + + // Cleanup + taskStore.cleanup(); + }); + + describe('Error scenarios', () => { + let taskStore: InMemoryTaskStore; + let clientTaskStore: InMemoryTaskStore; + + beforeEach(() => { + taskStore = new InMemoryTaskStore(); + clientTaskStore = new InMemoryTaskStore(); + }); + + afterEach(() => { + taskStore?.cleanup(); + clientTaskStore?.cleanup(); + }); + + test('should throw error when client queries non-existent task from server', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to query a task that doesn't exist + await expect(client.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); + }); + + test('should throw error when server queries non-existent task from client', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async () => ({ + action: 'accept', + content: { username: 'test' } + })); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Try to query a task that doesn't exist on client + await expect(server.experimental.tasks.getTask('non-existent-task')).rejects.toThrow(); + }); + }); +}); + +test('should respect client task capabilities', async () => { + const clientTaskStore = new InMemoryTaskStore(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + sampling: {}, + elicitation: {}, + tasks: { + requests: { + elicitation: { + create: {} + } + }, + + taskStore: clientTaskStore + } + } + } + ); + + client.setRequestHandler('elicitation/create', async (request, ctx) => { + const result = { + action: 'accept', + content: { username: 'test-user' } + }; + + // Check if task creation is requested + if (request.params.task && ctx.task?.store) { + const task = await ctx.task.store.createTask({ + ttl: ctx.task.requestedTtl + }); + await ctx.task.store.storeTaskResult(task.taskId, 'completed', result); + // Return CreateTaskResult when task creation is requested + return { task }; + } + + // Return ElicitResult for non-task requests + return result; + }); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + }, + enforceStrictCapabilities: true + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Client supports task creation for elicitation/create and task methods + expect(server.getClientCapabilities()).toEqual({ + sampling: {}, + elicitation: { + form: {} + }, + tasks: { + requests: { + elicitation: { + create: {} + } + } + } + }); + + // These should work because client supports tasks + const createTaskResult = await server.request( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'Test', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } } + } + } + }, + { task: { ttl: 60_000 } } + ); + + // Verify CreateTaskResult structure + expect(createTaskResult.task).toBeDefined(); + expect(createTaskResult.task.taskId).toBeDefined(); + const taskId = createTaskResult.task.taskId; + + await expect(server.experimental.tasks.listTasks()).resolves.not.toThrow(); + await expect(server.experimental.tasks.getTask(taskId)).resolves.not.toThrow(); + + // This should throw because client doesn't support task creation for sampling/createMessage + await expect( + server.request( + { + method: 'sampling/createMessage', + params: { + messages: [], + maxTokens: 10 + } + }, + { task: { taskId: 'test-task-2', keepAlive: 60_000 } } + ) + ).rejects.toThrow('Client does not support task creation for sampling/createMessage'); + + clientTaskStore.cleanup(); +}); + +describe('elicitInputStream', () => { + let server: Server; + let client: Client; + let clientTransport: ReturnType[0]; + let serverTransport: ReturnType[1]; + + beforeEach(async () => { + server = new Server( + { name: 'test server', version: '1.0' }, + { + capabilities: { + tasks: { + taskStore: new InMemoryTaskStore() + } + } + } + ); + + client = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {}, + url: {} + } + } + } + ); + + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }); + + afterEach(async () => { + await server.close().catch(() => {}); + await client.close().catch(() => {}); + }); + + test('should throw when client does not support form elicitation', async () => { + // Create client without form elicitation capability + const noFormClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const [noFormClientTransport, noFormServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noFormClient.connect(noFormClientTransport), server.connect(noFormServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { type: 'object', properties: {} } + }); + }).toThrow('Client does not support form elicitation.'); + + await noFormClient.close().catch(() => {}); + }); + + test('should throw when client does not support url elicitation', async () => { + // Create client without url elicitation capability + const noUrlClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const [noUrlClientTransport, noUrlServerTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([noUrlClient.connect(noUrlClientTransport), server.connect(noUrlServerTransport)]); + + expect(() => { + server.experimental.tasks.elicitInputStream({ + mode: 'url', + message: 'Open URL', + elicitationId: 'test-123', + url: 'https://example.com/auth' + }); + }).toThrow('Client does not support url elicitation.'); + + await noUrlClient.close().catch(() => {}); + }); + + test('should default to form mode when mode is not specified', async () => { + const requestStreamSpy = vi.spyOn(server.experimental.tasks, 'requestStream'); + + client.setRequestHandler('elicitation/create', () => ({ + action: 'accept', + content: { value: 'test' } + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call without explicit mode + const params = { + message: 'Enter value', + requestedSchema: { + type: 'object' as const, + properties: { value: { type: 'string' as const } } + } + }; + + const stream = server.experimental.tasks.elicitInputStream( + params as Parameters[0] + ); + await toArrayAsync(stream); + + // Verify mode was normalized to 'form' + expect(requestStreamSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'elicitation/create', + params: expect.objectContaining({ mode: 'form' }) + }), + undefined + ); + }); + + test('should yield error as terminal message when client returns error', async () => { + client.setRequestHandler('elicitation/create', () => { + throw new Error('Simulated client error'); + }); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Enter data', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const allMessages = await toArrayAsync(stream); + + expect(allMessages.length).toBe(1); + expect(allMessages[0].type).toBe('error'); + }); + + // For any streaming elicitation request, the AsyncGenerator yields exactly one terminal + // message (either 'result' or 'error') as its final message. + describe('terminal message guarantees', () => { + test.each([ + { action: 'accept' as const, content: { data: 'test-value' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield exactly one terminal message for action: $action', async ({ action, content }) => { + client.setRequestHandler('elicitation/create', () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Test message', + requestedSchema: { + type: 'object', + properties: { data: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Count terminal messages (result or error) + const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error'); + + expect(terminalMessages.length).toBe(1); + + // Verify terminal message is the last message + const lastMessage = messages.at(-1); + expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true); + + // Verify result content matches expected action + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe(action); + } + }); + }); + + // For any non-task elicitation request, the generator yields exactly one 'result' message + // (or 'error' if the request fails), with no 'taskCreated' or 'taskStatus' messages. + describe('non-task request minimality', () => { + test.each([ + { action: 'accept' as const, content: { value: 'test' } }, + { action: 'decline' as const, content: undefined }, + { action: 'cancel' as const, content: undefined } + ])('should yield only result message for non-task request with action: $action', async ({ action, content }) => { + client.setRequestHandler('elicitation/create', () => ({ + action, + content + })); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Non-task request (no task option) + const stream = server.experimental.tasks.elicitInputStream({ + mode: 'form', + message: 'Non-task request', + requestedSchema: { + type: 'object', + properties: { value: { type: 'string' } } + } + }); + + const messages = await toArrayAsync(stream); + + // Verify no taskCreated or taskStatus messages + const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus'); + expect(taskMessages.length).toBe(0); + + // Verify exactly one result message + const resultMessages = messages.filter(m => m.type === 'result'); + expect(resultMessages.length).toBe(1); + + // Verify total message count is 1 + expect(messages.length).toBe(1); + }); + }); + + // For any task-augmented elicitation request, the generator should yield at least one + // 'taskCreated' message followed by 'taskStatus' messages before yielding the final + // result or error. + describe('task-augmented request handling', () => { + test('should yield taskCreated and result for task-augmented request', async () => { + const clientTaskStore = new InMemoryTaskStore(); + const taskClient = new Client( + { name: 'test client', version: '1.0' }, + { + capabilities: { + elicitation: { form: {} }, + tasks: { + taskStore: clientTaskStore, + requests: { + elicitation: { create: {} } + } + } + } + } + ); + + taskClient.setRequestHandler('elicitation/create', async (request, extra) => { + const result = { + action: 'accept' as const, + content: { username: 'task-user' } + }; + + if (request.params.task && extra.task?.store) { + const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl }); + await extra.task.store.storeTaskResult(task.taskId, 'completed', result); + return { task }; + } + return result; + }); + + const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]); + + const stream = server.experimental.tasks.elicitInputStream( + { + mode: 'form', + message: 'Task-augmented request', + requestedSchema: { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'] + } + }, + { task: { ttl: 60_000 } } + ); + + const messages = await toArrayAsync(stream); + + // Should have taskCreated and result + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0].type).toBe('taskCreated'); + const taskCreated = messages[0] as { type: 'taskCreated'; task: Task }; + expect(taskCreated.task.taskId).toBeDefined(); + + // Last message should be result + const lastMessage = messages.at(-1); + expect(lastMessage.type).toBe('result'); + if (lastMessage.type === 'result') { + expect((lastMessage.result as ElicitResult).action).toBe('accept'); + expect((lastMessage.result as ElicitResult).content).toEqual({ username: 'task-user' }); + } + + clientTaskStore.cleanup(); + await taskClient.close().catch(() => {}); + }); + }); +}); + +describe('Server registerCapabilities with logging', () => { + test('registerCapabilities should register logging/setLevel handler', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }); + server.registerCapabilities({ logging: {} }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + // logging/setLevel should succeed, not throw "Method not found" + await expect(client.setLoggingLevel('error')).resolves.not.toThrow(); + + await client.close(); + await server.close(); + }); + + test('logging in constructor capabilities should register logging/setLevel handler', async () => { + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + await expect(client.setLoggingLevel('error')).resolves.not.toThrow(); + + await client.close(); + await server.close(); + }); +}); diff --git a/test/integration/test/server/bun.test.ts b/test/integration/test/server/bun.test.ts new file mode 100644 index 0000000..229145a --- /dev/null +++ b/test/integration/test/server/bun.test.ts @@ -0,0 +1,57 @@ +/** + * Bun integration test + * + * Verifies the MCP server and client packages work natively on Bun. + * Run with: bun test test/server/bun.test.ts + */ + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +// eslint-disable-next-line import/no-unresolved +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import * as z from 'zod/v4'; + +describe('MCP on Bun', () => { + let httpServer: ReturnType; + let transport: WebStandardStreamableHTTPServerTransport; + + beforeAll(async () => { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet someone', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => ({ + content: [{ type: 'text' as const, text: `Hello, ${name}!` }] + }) + ); + + transport = new WebStandardStreamableHTTPServerTransport(); + await mcpServer.connect(transport); + + httpServer = Bun.serve({ + port: 0, + fetch: req => transport.handleRequest(req) + }); + }); + + afterAll(async () => { + await transport?.close(); + httpServer?.stop(); + }); + + it('should handle MCP tool calls', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const clientTransport = new StreamableHTTPClientTransport(new URL(`http://localhost:${httpServer.port}`)); + + await client.connect(clientTransport); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'Bun' } }); + expect(result.content).toEqual([{ type: 'text', text: 'Hello, Bun!' }]); + + await client.close(); + }); +}); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts new file mode 100644 index 0000000..9c2d73a --- /dev/null +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -0,0 +1,177 @@ +/** + * Cloudflare Workers integration test + * + * Verifies the MCP server package works in Cloudflare Workers + * WITHOUT nodejs_compat, using runtime shims for cross-platform compatibility. + */ + +import type { ChildProcess } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import path from 'node:path'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const PORT = 8787; + +interface TestEnv { + tempDir: string; + process: ChildProcess; + cleanup: () => Promise; +} + +describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { + let env: TestEnv | null = null; + + beforeAll(async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cf-worker-test-')); + + // Pack server package + const serverPkgPath = path.resolve(__dirname, '../../../../packages/server'); + const packOutput = execSync(`pnpm pack --pack-destination ${tempDir}`, { + cwd: serverPkgPath, + encoding: 'utf8' + }); + const tarballName = path.basename(packOutput.trim().split('\n').pop()!); + + // Write package.json + const pkgJson = { + name: 'cf-worker-test', + private: true, + type: 'module', + dependencies: { + '@modelcontextprotocol/server': `file:./${tarballName}`, + '@cfworker/json-schema': '^4.1.1' + }, + devDependencies: { + wrangler: '^4.14.4' + } + }; + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify(pkgJson, null, 2)); + + // Write wrangler config + const wranglerConfig = { + $schema: 'node_modules/wrangler/config-schema.json', + name: 'cf-worker-test', + main: 'server.ts', + compatibility_date: '2025-01-01' + }; + fs.writeFileSync(path.join(tempDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2)); + + // Write server source + const serverSource = ` +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; + +const server = new McpServer({ name: "test-server", version: "1.0.0" }); + +server.registerTool("greet", { + description: "Greet someone" +}, async (args) => ({ + content: [{ type: "text", text: "Hello, " + (args.name || "World") + "!" }] +})); + +const transport = new WebStandardStreamableHTTPServerTransport(); +await server.connect(transport); + +export default { + fetch: (request) => transport.handleRequest(request) +}; +`; + fs.writeFileSync(path.join(tempDir, 'server.ts'), serverSource); + + // Install dependencies + execSync('npm install', { cwd: tempDir, stdio: 'pipe', timeout: 60_000 }); + + // Start wrangler dev server + const proc = spawn('npx', ['wrangler', 'dev', '--local', '--port', String(PORT)], { + cwd: tempDir, + shell: true, + stdio: 'pipe' + }); + + // Wait for server to be ready + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Wrangler startup timeout')), 60_000); + let stderrData = ''; + + proc.stdout?.on('data', data => { + const output = data.toString(); + if (/Ready on|Listening on/.test(output)) { + clearTimeout(timeout); + // Extra delay for wrangler to fully initialize + setTimeout(resolve, 1000); + } + }); + + proc.stderr?.on('data', data => { + stderrData += data.toString(); + // Check for fatal errors like missing node: modules + if (/No such module "node:/.test(stderrData)) { + clearTimeout(timeout); + reject(new Error(`Wrangler fatal error: ${stderrData}`)); + } + }); + + proc.on('error', err => { + clearTimeout(timeout); + reject(err); + }); + + proc.on('close', code => { + if (code !== 0 && code !== null) { + clearTimeout(timeout); + reject(new Error(`Wrangler exited with code ${code}. stderr: ${stderrData}`)); + } + }); + }); + + const cleanup = async () => { + proc.kill('SIGTERM'); + await new Promise(resolve => { + proc.on('close', () => resolve()); + setTimeout(resolve, 5000); + }); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }; + + env = { tempDir, process: proc, cleanup }; + }, 120_000); + + afterAll(async () => { + await env?.cleanup(); + }); + + it('should handle MCP requests', async () => { + expect(env).not.toBeNull(); + + // Retry connection — wrangler may report "Ready" before it can handle requests + let client!: Client; + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + try { + client = new Client({ name: 'test-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/`)); + await client.connect(transport); + lastError = undefined; + break; + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + if (lastError) { + throw lastError; + } + + const result = await client.callTool({ name: 'greet', arguments: { name: 'World' } }); + expect(result.content).toEqual([{ type: 'text', text: 'Hello, World!' }]); + + await client.close(); + }, 30_000); +}); diff --git a/test/integration/test/server/deno.test.ts b/test/integration/test/server/deno.test.ts new file mode 100644 index 0000000..7d45f81 --- /dev/null +++ b/test/integration/test/server/deno.test.ts @@ -0,0 +1,53 @@ +/** + * Deno integration test + * + * Verifies the MCP server and client packages work natively on Deno. + * Run with: deno test --no-check --allow-net --allow-read --allow-env test/server/deno.test.ts + */ + +import assert from 'node:assert/strict'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +Deno.test({ + name: 'MCP tool calls work on Deno', + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet someone', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => ({ + content: [{ type: 'text' as const, text: `Hello, ${name}!` }] + }) + ); + + const transport = new WebStandardStreamableHTTPServerTransport(); + await mcpServer.connect(transport); + + const httpServer = Deno.serve({ port: 0 }, req => transport.handleRequest(req)); + const port = httpServer.addr.port; + + try { + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const clientTransport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}`)); + + await client.connect(clientTransport); + + const result = await client.callTool({ name: 'greet', arguments: { name: 'Deno' } }); + assert.deepStrictEqual(result.content, [{ type: 'text', text: 'Hello, Deno!' }]); + + await client.close(); + } finally { + await transport.close(); + await httpServer.shutdown(); + } + } +}); diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts new file mode 100644 index 0000000..640e7b6 --- /dev/null +++ b/test/integration/test/server/elicitation.test.ts @@ -0,0 +1,987 @@ +/** + * Comprehensive elicitation flow tests with validator integration + * + * These tests verify the end-to-end elicitation flow from server requesting + * input to client responding and validation of the response against schemas. + * + * Per the MCP spec, elicitation only supports object schemas, not primitives. + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { ElicitRequestFormParams } from '@modelcontextprotocol/core'; +import { AjvJsonSchemaValidator, InMemoryTransport } from '@modelcontextprotocol/core'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core/validators/cfWorker'; +import { Server } from '@modelcontextprotocol/server'; + +const ajvProvider = new AjvJsonSchemaValidator(); +const cfWorkerProvider = new CfWorkerJsonSchemaValidator(); + +let server: Server; +let client: Client; + +describe('Elicitation Flow', () => { + describe('with AJV validator', () => { + beforeEach(async () => { + server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: ajvProvider + } + ); + + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + + testElicitationFlow(ajvProvider, 'AJV'); + }); + + describe('with CfWorker validator', () => { + beforeEach(async () => { + server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: cfWorkerProvider + } + ); + + client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + + testElicitationFlow(cfWorkerProvider, 'CfWorker'); + }); +}); + +function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) { + test(`${validatorName}: should elicit simple object with string field`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { name: 'John Doe' } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 } + }, + required: ['name'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { name: 'John Doe' } + }); + }); + + test(`${validatorName}: should elicit object with integer field`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { age: 42 } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'What is your age?', + requestedSchema: { + type: 'object', + properties: { + age: { type: 'integer', minimum: 0, maximum: 150 } + }, + required: ['age'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { age: 42 } + }); + }); + + test(`${validatorName}: should elicit object with boolean field`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { agree: true } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Do you agree?', + requestedSchema: { + type: 'object', + properties: { + agree: { type: 'boolean' } + }, + required: ['agree'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { agree: true } + }); + }); + + test(`${validatorName}: should elicit complex object with multiple fields`, async () => { + const userData = { + name: 'Jane Smith', + email: 'jane@example.com', + age: 28, + street: '123 Main St', + city: 'San Francisco', + zipCode: '94105', + newsletter: true, + notifications: false + }; + + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: userData + })); + + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + email: { type: 'string', format: 'email' }, + age: { type: 'integer', minimum: 0, maximum: 150 }, + street: { type: 'string' }, + city: { type: 'string' }, + // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator + zipCode: { type: 'string', pattern: '^[0-9]{5}$' }, + newsletter: { type: 'boolean' }, + notifications: { type: 'boolean' } + }, + required: ['name', 'email', 'age', 'street', 'city', 'zipCode'] + } + }; + const result = await server.elicitInput(formRequestParams); + + expect(result).toEqual({ + action: 'accept', + content: userData + }); + }); + + test(`${validatorName}: should reject invalid object (missing required field)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + email: 'user@example.com' + // Missing required 'name' field + } + })); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + email: { type: 'string' } + }, + required: ['name', 'email'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid field type`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + name: 'John Doe', + age: 'thirty' // Wrong type - should be integer + } + })); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + }, + required: ['name', 'age'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid string (too short)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { name: '' } // Too short + })); + + await expect( + server.elicitInput({ + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 } + }, + required: ['name'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid integer (out of range)`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { age: 200 } // Too high + })); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'What is your age?', + requestedSchema: { + type: 'object', + properties: { + age: { type: 'integer', minimum: 0, maximum: 150 } + }, + required: ['age'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid pattern`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { zipCode: 'ABC123' } // Doesn't match pattern + })); + + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', + message: 'Enter a 5-digit zip code', + requestedSchema: { + type: 'object', + properties: { + // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator + zipCode: { type: 'string', pattern: '^[0-9]{5}$' } + }, + required: ['zipCode'] + } + }; + + await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); + }); + + test(`${validatorName}: should allow decline action without validation`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'decline' + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + } + }); + + expect(result).toEqual({ + action: 'decline' + }); + }); + + test(`${validatorName}: should allow cancel action without validation`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'cancel' + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'] + } + }); + + expect(result).toEqual({ + action: 'cancel' + }); + }); + + test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => { + let requestCount = 0; + client.setRequestHandler('elicitation/create', request => { + requestCount++; + if (request.params.message.includes('name')) { + return { action: 'accept', content: { name: 'Alice' } }; + } else if (request.params.message.includes('age')) { + return { action: 'accept', content: { age: 30 } }; + } else if (request.params.message.includes('city')) { + return { action: 'accept', content: { city: 'New York' } }; + } + return { action: 'decline' }; + }); + + const nameResult = await server.elicitInput({ + mode: 'form', + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { name: { type: 'string', minLength: 1 } }, + required: ['name'] + } + }); + + const ageResult = await server.elicitInput({ + message: 'What is your age?', + requestedSchema: { + type: 'object', + properties: { age: { type: 'integer', minimum: 0 } }, + required: ['age'] + } + }); + + const cityResult = await server.elicitInput({ + message: 'What is your city?', + requestedSchema: { + type: 'object', + properties: { city: { type: 'string', minLength: 1 } }, + required: ['city'] + } + }); + + expect(requestCount).toBe(3); + expect(nameResult).toEqual({ + action: 'accept', + content: { name: 'Alice' } + }); + expect(ageResult).toEqual({ action: 'accept', content: { age: 30 } }); + expect(cityResult).toEqual({ + action: 'accept', + content: { city: 'New York' } + }); + }); + + test(`${validatorName}: should validate with optional fields present`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { name: 'John', nickname: 'Johnny' } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Enter your name', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + nickname: { type: 'string' } + }, + required: ['name'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { name: 'John', nickname: 'Johnny' } + }); + }); + + test(`${validatorName}: should validate with optional fields absent`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { name: 'John' } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Enter your name', + requestedSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1 }, + nickname: { type: 'string' } + }, + required: ['name'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { name: 'John' } + }); + }); + + test(`${validatorName}: should validate email format`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { email: 'user@example.com' } + })); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Enter your email', + requestedSchema: { + type: 'object', + properties: { + email: { type: 'string', format: 'email' } + }, + required: ['email'] + } + }); + + expect(result).toEqual({ + action: 'accept', + content: { email: 'user@example.com' } + }); + }); + + test(`${validatorName}: should default missing fields from schema defaults`, async () => { + const server = new Server( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + jsonSchemaValidator: validatorProvider + } + ); + + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + capabilities: { + elicitation: { + form: { + applyDefaults: true + } + } + } + } + ); + + const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = { + type: 'object', + properties: { + subscribe: { type: 'boolean', default: true }, + nickname: { type: 'string', default: 'Guest' }, + age: { type: 'integer', minimum: 0, maximum: 150, default: 18 }, + color: { type: 'string', enum: ['red', 'green'], default: 'green' }, + untitledSingleSelectEnum: { + type: 'string', + title: 'Untitled Single Select Enum', + description: 'Choose your favorite color', + enum: ['red', 'green', 'blue'], + default: 'green' + }, + untitledMultipleSelectEnum: { + type: 'array', + title: 'Untitled Multiple Select Enum', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { type: 'string', enum: ['red', 'green', 'blue'] }, + default: ['green', 'blue'] + }, + titledSingleSelectEnum: { + type: 'string', + title: 'Single Select Enum', + description: 'Choose your favorite color', + oneOf: [ + { const: 'red', title: 'Red' }, + { const: 'green', title: 'Green' }, + { const: 'blue', title: 'Blue' } + ], + default: 'green' + }, + titledMultipleSelectEnum: { + type: 'array', + title: 'Multiple Select Enum', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: 'red', title: 'Red' }, + { const: 'green', title: 'Green' }, + { const: 'blue', title: 'Blue' } + ] + }, + default: ['green', 'blue'] + }, + legacyTitledEnum: { + type: 'string', + title: 'Legacy Titled Enum', + description: 'Choose your favorite color', + enum: ['red', 'green', 'blue'], + enumNames: ['Red', 'Green', 'Blue'], + default: 'green' + }, + optionalWithADefault: { type: 'string', default: 'default value' } + }, + required: [ + 'subscribe', + 'nickname', + 'age', + 'color', + 'titledSingleSelectEnum', + 'titledMultipleSelectEnum', + 'untitledSingleSelectEnum', + 'untitledMultipleSelectEnum' + ] + }; + + // Client returns no values; SDK should apply defaults automatically (and validate) + client.setRequestHandler('elicitation/create', request => { + expect(request.params.mode).toEqual('form'); + expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties); + return { + action: 'accept', + content: {} + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.elicitInput({ + mode: 'form', + message: 'Provide your preferences', + requestedSchema: testSchemaProperties + }); + + expect(result).toEqual({ + action: 'accept', + content: { + subscribe: true, + nickname: 'Guest', + age: 18, + color: 'green', + untitledSingleSelectEnum: 'green', + untitledMultipleSelectEnum: ['green', 'blue'], + titledSingleSelectEnum: 'green', + titledMultipleSelectEnum: ['green', 'blue'], + legacyTitledEnum: 'green', + optionalWithADefault: 'default value' + } + }); + }); + + test(`${validatorName}: should reject invalid email format`, async () => { + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { email: 'not-an-email' } + })); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Enter your email', + requestedSchema: { + type: 'object', + properties: { + email: { type: 'string', format: 'email' } + }, + required: ['email'] + } + }) + ).rejects.toThrow(/does not match requested schema/); + }); + + // Enums - Valid - Single Select - Untitled / Titled + + test(`${validatorName}: should succeed with valid selection in single-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: 'Red' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['Red', 'Green', 'Blue'], + default: 'Green' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: 'Red' + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in single-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: '#FF0000' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + oneOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: '#FF0000' + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in single-select titled legacy enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: '#FF0000' + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['#FF0000', '#00FF00', '#0000FF'], + enumNames: ['Red', 'Green', 'Blue'], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + color: '#FF0000' + } + }); + }); + + // Enums - Valid - Multi Select - Untitled / Titled + + test(`${validatorName}: should succeed with valid selection in multi-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['Red', 'Green', 'Blue'] + } + } + }, + required: ['colors'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] + } + }); + }); + + test(`${validatorName}: should succeed with valid selection in multi-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + colors: ['#FF0000', '#0000FF'] + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ] + } + } + }, + required: ['colors'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + colors: ['#FF0000', '#0000FF'] + } + }); + }); + + // Enums - Invalid - Single Select - Untitled / Titled + + test(`${validatorName}: should reject invalid selection in single-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: 'Black' // Color not in enum list + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['Red', 'Green', 'Blue'], + default: 'Green' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in single-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be "#FF0000" (const not title) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + oneOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in single-select titled legacy enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be "#FF0000" (enum not enumNames) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'string', + title: 'Color Selection', + description: 'Choose your favorite color', + enum: ['#FF0000', '#00FF00', '#0000FF'], + enumNames: ['Red', 'Green', 'Blue'], + default: '#00FF00' + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^Elicitation response content does not match requested schema/); + }); + + // Enums - Invalid - Multi Select - Untitled / Titled + + test(`${validatorName}: should reject invalid selection in multi-select untitled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + color: 'Red' // Should be array, not string + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + color: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + type: 'string', + enum: ['Red', 'Green', 'Blue'] + } + } + }, + required: ['color'] + } + }) + ).rejects.toThrow(/^Elicitation response content does not match requested schema/); + }); + + test(`${validatorName}: should reject invalid selection in multi-select titled enum`, async () => { + // Set up client to return valid response + client.setRequestHandler('elicitation/create', _request => ({ + action: 'accept', + content: { + colors: ['Red', 'Blue'] // Should be ["#FF0000", "#0000FF"] (const not title) + } + })); + + // Test with valid response + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + colors: { + type: 'array', + title: 'Color Selection', + description: 'Choose your favorite colors', + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: '#FF0000', title: 'Red' }, + { const: '#00FF00', title: 'Green' }, + { const: '#0000FF', title: 'Blue' } + ] + } + } + }, + required: ['colors'] + } + }) + ).rejects.toThrow(/^Elicitation response content does not match requested schema/); + }); +} diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts new file mode 100644 index 0000000..92af097 --- /dev/null +++ b/test/integration/test/server/mcp.test.ts @@ -0,0 +1,7042 @@ +import { Client } from '@modelcontextprotocol/client'; +import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core'; +import { + getDisplayName, + InMemoryTaskStore, + InMemoryTransport, + ProtocolErrorCode, + UriTemplate, + UrlElicitationRequiredError +} from '@modelcontextprotocol/core'; +import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +function createLatch() { + let latch = false; + const waitForLatch = async () => { + while (!latch) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + }; + + return { + releaseLatch: () => { + latch = true; + }, + waitForLatch + }; +} + +describe('Zod v4', () => { + describe('McpServer', () => { + /*** + * Test: Basic Server Instance + */ + test('should expose underlying Server instance', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + expect(mcpServer.server).toBeDefined(); + }); + + /*** + * Test: Notification Sending via Server + */ + test('should allow sending notifications via Server', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // This should work because we're using the underlying server + await expect( + mcpServer.server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); + + expect(notifications).toMatchObject([ + { + method: 'notifications/message', + params: { + level: 'info', + data: 'Test log message' + } + } + ]); + }); + + /*** + * Test: ctx.mcpReq.log convenience method + */ + test('should send logging messages via ctx.mcpReq.log() convenience method', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + mcpServer.registerTool( + 'log-test', + { + description: 'A tool that logs via ctx.mcpReq.log()' + }, + async ctx => { + await ctx.mcpReq.log('info', 'Log from convenience method', 'test-logger'); + return { + content: [{ type: 'text' as const, text: 'done' }] + }; + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.callTool({ name: 'log-test' }); + + expect(notifications).toMatchObject([ + { + method: 'notifications/message', + params: { + level: 'info', + data: 'Log from convenience method', + logger: 'test-logger' + } + } + ]); + }); + + /*** + * Test: ctx.mcpReq.elicitInput convenience method + */ + test('should elicit input via ctx.mcpReq.elicitInput() convenience method', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + let elicitResult: unknown = null; + + mcpServer.registerTool( + 'elicit-test', + { + description: 'A tool that elicits input via ctx.mcpReq.elicitInput()' + }, + async ctx => { + elicitResult = await ctx.mcpReq.elicitInput({ + message: 'Please confirm', + requestedSchema: { + type: 'object', + properties: { + confirmed: { type: 'boolean' } + } + } + }); + return { + content: [{ type: 'text' as const, text: 'done' }] + }; + } + ); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { elicitation: {} } }); + + client.setRequestHandler('elicitation/create', async () => ({ + action: 'accept', + content: { confirmed: true } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.callTool({ name: 'elicit-test' }); + + expect(elicitResult).toMatchObject({ + action: 'accept', + content: { confirmed: true } + }); + }); + + /*** + * Test: ctx.mcpReq.requestSampling convenience method + */ + test('should request sampling via ctx.mcpReq.requestSampling() convenience method', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + let samplingResult: unknown = null; + + mcpServer.registerTool( + 'sampling-test', + { + description: 'A tool that requests sampling via ctx.mcpReq.requestSampling()' + }, + async ctx => { + samplingResult = await ctx.mcpReq.requestSampling({ + messages: [ + { + role: 'user', + content: { type: 'text', text: 'Hello' } + } + ], + maxTokens: 100 + }); + return { + content: [{ type: 'text' as const, text: 'done' }] + }; + } + ); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler('sampling/createMessage', async () => ({ + model: 'test-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Hello back' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.callTool({ name: 'sampling-test' }); + + expect(samplingResult).toMatchObject({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Hello back' } + }); + }); + + /*** + * Test: Progress Notification with Message Field + */ + test('should send progress notifications with message field', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Create a tool that sends progress updates + mcpServer.registerTool( + 'long-operation', + { + description: 'A long running operation with progress updates', + inputSchema: z.object({ + steps: z.number().min(1).describe('Number of steps to perform') + }) + }, + async ({ steps }, ctx) => { + const progressToken = ctx.mcpReq._meta?.progressToken; + + if (progressToken) { + // Send progress notification for each step + for (let i = 1; i <= steps; i++) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { + progressToken, + progress: i, + total: steps, + message: `Completed step ${i} of ${steps}` + } + }); + } + } + + return { + content: [ + { + type: 'text' as const, + text: `Operation completed with ${steps} steps` + } + ] + }; + } + ); + + const progressUpdates: Array<{ + progress: number; + total?: number; + message?: string; + }> = []; + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool with progress tracking + await client.request( + { + method: 'tools/call', + params: { + name: 'long-operation', + arguments: { steps: 3 }, + _meta: { + progressToken: 'progress-test-1' + } + } + }, + { + onprogress: progress => { + progressUpdates.push(progress); + } + } + ); + + // Verify progress notifications were received with message field + expect(progressUpdates).toHaveLength(3); + expect(progressUpdates[0]).toMatchObject({ + progress: 1, + total: 3, + message: 'Completed step 1 of 3' + }); + expect(progressUpdates[1]).toMatchObject({ + progress: 2, + total: 3, + message: 'Completed step 2 of 3' + }); + expect(progressUpdates[2]).toMatchObject({ + progress: 3, + total: 3, + message: 'Completed step 3 of 3' + }); + }); + + /*** + * Test: Extensions capability registration + */ + test('should register and advertise server extensions capability', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.server.registerCapabilities({ + extensions: { + 'io.modelcontextprotocol/test-extension': { listChanged: true } + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ listChanged: true }); + }); + + test('should advertise client extensions capability to server', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + extensions: { + 'io.modelcontextprotocol/test-extension': { streaming: true } + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = mcpServer.server.getClientCapabilities(); + expect(capabilities?.extensions).toBeDefined(); + expect(capabilities?.extensions?.['io.modelcontextprotocol/test-extension']).toEqual({ streaming: true }); + }); + }); + + describe('ResourceTemplate', () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ + test('should create ResourceTemplate with string pattern', () => { + const template = new ResourceTemplate('test://{category}/{id}', { + list: undefined + }); + expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ + test('should create ResourceTemplate with UriTemplate', () => { + const uriTemplate = new UriTemplate('test://{category}/{id}'); + const template = new ResourceTemplate(uriTemplate, { list: undefined }); + expect(template.uriTemplate).toBe(uriTemplate); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate with List Callback + */ + test('should create ResourceTemplate with list callback', async () => { + const list = vi.fn().mockResolvedValue({ + resources: [{ name: 'Test', uri: 'test://example' }] + }); + + const template = new ResourceTemplate('test://{id}', { list }); + expect(template.listCallback).toBe(list); + + const abortController = new AbortController(); + const result = await template.listCallback?.({ + signal: abortController.signal, + requestId: 'not-implemented', + sendRequest: () => { + throw new Error('Not implemented'); + }, + sendNotification: () => { + throw new Error('Not implemented'); + } + }); + expect(result?.resources).toHaveLength(1); + expect(list).toHaveBeenCalled(); + }); + }); + + describe('tool()', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /*** + * Test: Zero-Argument Tool Registration + */ + test('should register zero-argument tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + mcpServer.registerTool('test', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/list' + }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.inputSchema).toEqual({ + type: 'object', + properties: {} + }); + + // Adding the tool before the connection was established means no notification was sent + expect(notifications).toHaveLength(0); + + // Adding another tool triggers the update notification + mcpServer.registerTool('test2', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { + method: 'notifications/tools/list_changed' + } + ]); + }); + + /*** + * Test: Updating Existing Tool + */ + test('should update existing tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.registerTool('test', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Initial response' + } + ] + })); + + // Update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool and verify we get the updated response + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test' + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Updated response' + } + ]); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Updating Tool with Schema + */ + test('should update tool with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ + name: z.string() + }) + }, + async ({ name }) => ({ + content: [ + { + type: 'text', + text: `Initial: ${name}` + } + ] + }) + ); + + // Update the tool with a different schema + tool.update({ + paramsSchema: z.object({ + name: z.string(), + value: z.number() + }), + callback: async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `Updated: ${name}, ${value}` + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify the schema was updated + const listResult = await client.request({ + method: 'tools/list' + }); + + expect(listResult.tools[0]!.inputSchema).toMatchObject({ + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); + + // Call the tool with the new schema + const callResult = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 42 + } + } + }); + + expect(callResult.content).toEqual([ + { + type: 'text', + text: 'Updated: test, 42' + } + ]); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Updating Tool with outputSchema + */ + test('should update tool with outputSchema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.registerTool( + 'test', + { + outputSchema: z.object({ + result: z.number() + }) + }, + async () => ({ + content: [{ type: 'text', text: '' }], + structuredContent: { + result: 42 + } + }) + ); + + // Update the tool with a different outputSchema + tool.update({ + outputSchema: z.object({ + result: z.number(), + sum: z.number() + }), + callback: async () => ({ + content: [{ type: 'text', text: '' }], + structuredContent: { + result: 42, + sum: 100 + } + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify the outputSchema was updated + const listResult = await client.request({ + method: 'tools/list' + }); + + expect(listResult.tools[0]!.outputSchema).toMatchObject({ + type: 'object', + properties: { + result: { type: 'number' }, + sum: { type: 'number' } + } + }); + + // Call the tool to verify it works with the updated outputSchema + const callResult = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: {} + } + }); + + expect(callResult.structuredContent).toEqual({ + result: 42, + sum: 100 + }); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Tool List Changed Notifications + */ + test('should send tool list changed notifications when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.registerTool('test', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); + + // Now delete the tool + tool.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { method: 'notifications/tools/list_changed' }, + { method: 'notifications/tools/list_changed' } + ]); + }); + + /*** + * Test: listChanged capability should default to true when not specified + */ + test('should default tools.listChanged to true when not explicitly set', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool('test', {}, async () => ({ + content: [{ type: 'text', text: 'Test' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.tools?.listChanged).toBe(true); + }); + + /*** + * Test: listChanged capability should respect explicit false setting + */ + test('should respect tools.listChanged: false when explicitly set', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { tools: { listChanged: false } } }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool('test', {}, async () => ({ + content: [{ type: 'text', text: 'Test' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.tools?.listChanged).toBe(false); + }); + + /*** + * Test: resources.listChanged should respect explicit false setting + */ + test('should respect resources.listChanged: false when explicitly set', async () => { + const mcpServer = new McpServer( + { name: 'test server', version: '1.0' }, + { capabilities: { resources: { listChanged: false } } } + ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('test://resource', 'Test Resource', async () => ({ + contents: [{ uri: 'test://resource', text: 'Test' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.resources?.listChanged).toBe(false); + }); + + /*** + * Test: prompts.listChanged should respect explicit false setting + */ + test('should respect prompts.listChanged: false when explicitly set', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' }, { capabilities: { prompts: { listChanged: false } } }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt('test-prompt', async () => ({ + messages: [{ role: 'assistant', content: { type: 'text', text: 'Test' } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities(); + expect(capabilities?.prompts?.listChanged).toBe(false); + }); + + /*** + * Test: Tool Registration with Parameters + */ + test('should register tool with params', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ name: z.string(), value: z.number() }) + }, + async ({ name, value }) => ({ + content: [{ type: 'text', text: `${name}: ${value}` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/list' + }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.inputSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); + }); + + /*** + * Test: Tool Registration with Description + */ + test('should register tool with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool('test', { description: 'Test description' }, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + // new api + mcpServer.registerTool( + 'test (new api)', + { + description: 'Test description' + }, + async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/list' + }); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.description).toBe('Test description'); + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.description).toBe('Test description'); + }); + + /*** + * Test: Tool Registration with Annotations + */ + test('should register tool with annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/list' + }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); + }); + + /*** + * Test: Tool Registration with Parameters and Annotations + */ + test('should register tool with params and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ name: z.string() }), + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0]!.annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); + }); + + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ + test('should register tool with description, params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'A tool with everything', + inputSchema: z.object({ name: z.string() }), + annotations: { + title: 'Complete Test Tool', + readOnlyHint: true, + openWorldHint: false + } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.description).toBe('A tool with everything'); + expect(result.tools[0]!.inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0]!.annotations).toEqual({ + title: 'Complete Test Tool', + readOnlyHint: true, + openWorldHint: false + }); + }); + + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ + test('should register tool with description, empty params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'A tool with everything but empty params', + annotations: { + title: 'Complete Test Tool with empty params', + readOnlyHint: true, + openWorldHint: false + } + }, + async () => ({ + content: [{ type: 'text', text: 'Test response' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.description).toBe('A tool with everything but empty params'); + expect(result.tools[0]!.inputSchema).toMatchObject({ + type: 'object', + properties: {} + }); + expect(result.tools[0]!.annotations).toEqual({ + title: 'Complete Test Tool with empty params', + readOnlyHint: true, + openWorldHint: false + }); + }); + + /*** + * Test: Tool Argument Validation + */ + test('should validate tool args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + inputSchema: z.object({ + name: z.string(), + value: z.number() + }) + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'not a number' + } + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Input validation error: Invalid arguments for tool test') + } + ]) + ); + }); + + /*** + * Test: Preventing Duplicate Tool Registration + */ + test('should prevent duplicate tool registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.registerTool('test', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + expect(() => { + mcpServer.registerTool('test', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response 2' + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Tool Registration + */ + test('should allow registering multiple tools', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.registerTool('tool1', {}, () => ({ content: [] })); + + // This should also succeed and not throw about request handlers + mcpServer.registerTool('tool2', {}, () => ({ content: [] })); + }); + + /*** + * Test: Tool with Output Schema and Structured Content + */ + test('should support tool with outputSchema and structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema + mcpServer.registerTool( + 'test', + { + description: 'Test tool with structured output', + inputSchema: z.object({ + input: z.string() + }), + outputSchema: z.object({ + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }) + }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }, + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }) + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request({ + method: 'tools/list' + }); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0]!.outputSchema).toMatchObject({ + type: 'object', + properties: { + processedInput: { type: 'string' }, + resultType: { type: 'string' }, + timestamp: { type: 'string' } + }, + required: ['processedInput', 'resultType', 'timestamp'] + }); + + // Call the tool and verify it returns valid structuredContent + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }); + + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe('hello'); + expect(structuredContent.resultType).toBe('structured'); + expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: 'text' }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); + }); + + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: z.object({ + input: z.string() + }), + outputSchema: z.object({ + processedInput: z.string(), + resultType: z.string() + }) + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw an error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining( + 'Output validation error: Tool test has an output schema but no structured content was provided' + ) + } + ]) + ); + }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should skip outputSchema validation when isError is true', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: z.object({ + input: z.string() + }), + outputSchema: z.object({ + processedInput: z.string(), + resultType: z.string() + }) + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ], + isError: true + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }) + ).resolves.toStrictEqual({ + content: [ + { + type: 'text', + text: `Processed: hello` + } + ], + isError: true + }); + }); + + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test('should fail schema validation when tool returns invalid structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns invalid data + mcpServer.registerTool( + 'test', + { + description: 'Test tool with invalid structured output', + inputSchema: z.object({ + input: z.string() + }), + outputSchema: z.object({ + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + }) + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + }) + } + ], + structuredContent: { + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + } + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw a server-side validation error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Output validation error: Invalid structured content for tool test') + } + ]) + ); + }); + + /*** + * Test: Pass Session ID to Tool Callback + */ + test('should pass sessionId to tool callback via ServerContext', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedSessionId: string | undefined; + mcpServer.registerTool('test-tool', {}, async ctx => { + receivedSessionId = ctx.sessionId; + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Set a test sessionId on the server transport + serverTransport.sessionId = 'test-session-123'; + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.request({ + method: 'tools/call', + params: { + name: 'test-tool' + } + }); + + expect(receivedSessionId).toBe('test-session-123'); + }); + + /*** + * Test: Pass Request ID to Tool Callback + */ + test('should pass requestId to tool callback via ServerContext', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.registerTool('request-id-test', {}, async ctx => { + receivedRequestId = ctx.mcpReq.id; + return { + content: [ + { + type: 'text', + text: `Received request ID: ${ctx.mcpReq.id}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'request-id-test' + } + }); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Received request ID:') + } + ]) + ); + }); + + /*** + * Test: Send Notification within Tool Call + */ + test('should provide sendNotification within tool call', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedLogMessage: string | undefined; + const loggingMessage = 'hello here is log message 1'; + + client.setNotificationHandler('notifications/message', notification => { + receivedLogMessage = notification.params.data as string; + }); + + mcpServer.registerTool('test-tool', {}, async ctx => { + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { level: 'debug', data: loggingMessage } + }); + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await client.request({ + method: 'tools/call', + params: { + name: 'test-tool' + } + }); + expect(receivedLogMessage).toBe(loggingMessage); + }); + + /*** + * Test: Client to Server Tool Call + */ + test('should allow client to call server tools', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'Test tool', + inputSchema: z.object({ + input: z.string() + }) + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: hello' + } + ]); + }); + + /*** + * Test: Graceful Tool Error Handling + */ + test('should handle server tool errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool('error-test', {}, async () => { + throw new Error('Tool execution failed'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'error-test' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Tool execution failed' + } + ]); + }); + + /*** + * Test: ProtocolError for Invalid Tool Name + */ + test('should throw ProtocolError for invalid tool name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool('test-tool', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'tools/call', + params: { + name: 'nonexistent-tool' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('nonexistent-tool') + }); + }); + + /*** + * Test: ProtocolError for Disabled Tool + */ + test('should throw ProtocolError for disabled tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const tool = mcpServer.registerTool('test-tool', {}, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + tool.disable(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'tools/call', + params: { + name: 'test-tool' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); + }); + + /*** + * Test: URL Elicitation Required Error Propagation + */ + test('should propagate UrlElicitationRequiredError to client callers', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const elicitationParams = { + mode: 'url' as const, + elicitationId: 'elicitation-123', + url: 'https://mcp.example.com/connect', + message: 'Authorization required' + }; + + mcpServer.registerTool('needs-authorization', {}, async () => { + throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client + .callTool({ + name: 'needs-authorization' + }) + .then(() => { + throw new Error('Expected callTool to throw UrlElicitationRequiredError'); + }) + .catch(error => { + expect(error).toBeInstanceOf(UrlElicitationRequiredError); + if (error instanceof UrlElicitationRequiredError) { + expect(error.code).toBe(ProtocolErrorCode.UrlElicitationRequired); + expect(error.elicitations).toEqual([elicitationParams]); + } + }); + }); + + /*** + * Test: Tool Registration with _meta field + */ + test('should register tool with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; + + mcpServer.registerTool( + 'test-with-meta', + { + description: 'A tool with _meta field', + inputSchema: z.object({ name: z.string() }), + _meta: metaData + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test-with-meta'); + expect(result.tools[0]!.description).toBe('A tool with _meta field'); + expect(result.tools[0]!._meta).toEqual(metaData); + }); + + /*** + * Test: Tool Registration without _meta field should have undefined _meta + */ + test('should register tool without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test-without-meta', + { + description: 'A tool without _meta field', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('test-without-meta'); + expect(result.tools[0]!._meta).toBeUndefined(); + }); + + test('should include execution field in listTools response when tool has execution settings', async () => { + const taskStore = new InMemoryTaskStore(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with execution.taskSupport + mcpServer.experimental.tasks.registerToolTask( + 'task-tool', + { + description: 'A tool with task support', + inputSchema: z.object({ input: z.string() }), + execution: { + taskSupport: 'required' + } + }, + { + createTask: async (_args, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000 }); + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) throw new Error('Task not found'); + return task; + }, + getTaskResult: async (_args, ctx) => { + return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('task-tool'); + expect(result.tools[0]!.execution).toEqual({ + taskSupport: 'required' + }); + + taskStore.cleanup(); + }); + + test('should include execution field with taskSupport optional in listTools response', async () => { + const taskStore = new InMemoryTaskStore(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with execution.taskSupport optional + mcpServer.experimental.tasks.registerToolTask( + 'optional-task-tool', + { + description: 'A tool with optional task support', + inputSchema: z.object({ input: z.string() }), + execution: { + taskSupport: 'optional' + } + }, + { + createTask: async (_args, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000 }); + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) throw new Error('Task not found'); + return task; + }, + getTaskResult: async (_args, ctx) => { + return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]!.name).toBe('optional-task-tool'); + expect(result.tools[0]!.execution).toEqual({ + taskSupport: 'optional' + }); + + taskStore.cleanup(); + }); + + test('should validate tool names according to SEP specification', () => { + // Create a new server instance for this test + const testServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Spy on console.warn to verify warnings are logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Test valid tool names + testServer.registerTool( + 'valid-tool-name', + { + description: 'A valid tool name' + }, + async () => ({ content: [{ type: 'text', text: 'Success' }] }) + ); + + // Test tool name with warnings (starts with dash) + testServer.registerTool( + '-warning-tool', + { + description: 'A tool name that generates warnings' + }, + async () => ({ content: [{ type: 'text', text: 'Success' }] }) + ); + + // Test invalid tool name (contains spaces) + testServer.registerTool( + 'invalid tool name', + { + description: 'An invalid tool name' + }, + async () => ({ content: [{ type: 'text', text: 'Success' }] }) + ); + + // Verify that warnings were issued (both for warnings and validation failures) + expect(warnSpy).toHaveBeenCalled(); + + // Verify specific warning content + const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); + expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); + + // Clean up spies + warnSpy.mockRestore(); + }); + }); + + describe('resource()', () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ + test('should register resource with uri and readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.name).toBe('test'); + expect(result.resources[0]!.uri).toBe('test://resource'); + }); + + /*** + * Test: Update Resource with URI + */ + test('should update resource with uri', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Initial content' + } + ] + })); + + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Read the resource and verify we get the updated content + const result = await client.request({ + method: 'resources/read', + params: { + uri: 'test://resource' + } + }); + + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource' + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Update Resource Template + */ + test('should update resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource template + const resourceTemplate = mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + {}, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Initial content' + } + ] + }) + ); + + // Update the resource template + resourceTemplate.update({ + callback: async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Updated content' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Read the resource and verify we get the updated content + const result = await client.request({ + method: 'resources/read', + params: { + uri: 'test://resource/123' + } + }); + + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource/123' + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Resource List Changed Notification + */ + test('should send resource list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the resource while connected + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + }); + + /*** + * Test: Remove Resource and Send Notification + */ + test('should remove resource and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resources + const resource1 = mcpServer.registerResource('resource1', 'test://resource1', {}, async () => ({ + contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] + })); + + mcpServer.registerResource('resource2', 'test://resource2', {}, async () => ({ + contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify both resources are registered + let result = await client.request({ method: 'resources/list' }); + + expect(result.resources).toHaveLength(2); + + expect(notifications).toHaveLength(0); + + // Remove a resource + resource1.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + + // Verify the resource was removed + result = await client.request({ method: 'resources/list' }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.uri).toBe('test://resource2'); + }); + + /*** + * Test: Remove Resource Template and Send Notification + */ + test('should remove resource template and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register resource template + const resourceTemplate = mcpServer.registerResource( + 'template', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + {}, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Template content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify template is registered + const result = await client.request({ method: 'resources/templates/list' }); + + expect(result.resourceTemplates).toHaveLength(1); + expect(notifications).toHaveLength(0); + + // Remove the template + resourceTemplate.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + + // Verify the template was removed + const result2 = await client.request({ method: 'resources/templates/list' }); + + expect(result2.resourceTemplates).toHaveLength(0); + }); + + /*** + * Test: Resource Registration with Metadata + */ + test('should register resource with metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const mockDate = new Date().toISOString(); + mcpServer.registerResource( + 'test', + 'test://resource', + { + description: 'Test resource', + mimeType: 'text/plain', + annotations: { + audience: ['user'], + priority: 0.5, + lastModified: mockDate + } + }, + async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.description).toBe('Test resource'); + expect(result.resources[0]!.mimeType).toBe('text/plain'); + expect(result.resources[0]!.annotations).toEqual({ + audience: ['user'], + priority: 0.5, + lastModified: mockDate + }); + }); + + /*** + * Test: Resource Template Registration + */ + test('should register resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), {}, async () => ({ + contents: [ + { + uri: 'test://resource/123', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/templates/list' + }); + + expect(result.resourceTemplates).toHaveLength(1); + expect(result.resourceTemplates[0]!.name).toBe('test'); + expect(result.resourceTemplates[0]!.uriTemplate).toBe('test://resource/{id}'); + }); + + /*** + * Test: Resource Template with List Callback + */ + test('should register resource template with listCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + } + ] + }) + }), + {}, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(2); + expect(result.resources[0]!.name).toBe('Resource 1'); + expect(result.resources[0]!.uri).toBe('test://resource/1'); + expect(result.resources[1]!.name).toBe('Resource 2'); + expect(result.resources[1]!.uri).toBe('test://resource/2'); + }); + + /*** + * Test: Template Variables to Read Callback + */ + test('should pass template variables to readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}/{id}', { + list: undefined + }), + {}, + async (uri, { category, id }) => ({ + contents: [ + { + uri: uri.href, + text: `Category: ${category}, ID: ${id}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/read', + params: { + uri: 'test://resource/books/123' + } + }); + + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Category: books, ID: 123'), + uri: 'test://resource/books/123' + } + ]) + ); + }); + + /*** + * Test: Preventing Duplicate Resource Registration + */ + test('should prevent duplicate resource registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + expect(() => { + mcpServer.registerResource('test2', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Resource Registration + */ + test('should allow registering multiple resources', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.registerResource('resource1', 'test://resource1', {}, async () => ({ + contents: [ + { + uri: 'test://resource1', + text: 'Test content 1' + } + ] + })); + + // This should also succeed and not throw about request handlers + mcpServer.registerResource('resource2', 'test://resource2', {}, async () => ({ + contents: [ + { + uri: 'test://resource2', + text: 'Test content 2' + } + ] + })); + }); + + /*** + * Test: Preventing Duplicate Resource Template Registration + */ + test('should prevent duplicate resource template registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.registerResource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), {}, async () => ({ + contents: [ + { + uri: 'test://resource/123', + text: 'Test content' + } + ] + })); + + expect(() => { + mcpServer.registerResource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), {}, async () => ({ + contents: [ + { + uri: 'test://resource/123', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Graceful Resource Read Error Handling + */ + test('should handle resource read errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('error-test', 'test://error', {}, async () => { + throw new Error('Resource read failed'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'resources/read', + params: { + uri: 'test://error' + } + }) + ).rejects.toThrow(/Resource read failed/); + }); + + /*** + * Test: ProtocolError for Invalid Resource URI + */ + test('should throw ProtocolError for invalid resource URI', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'resources/read', + params: { + uri: 'test://nonexistent' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.ResourceNotFound, + message: expect.stringContaining('not found') + }); + }); + + /*** + * Test: ProtocolError for Disabled Resource + */ + test('should throw ProtocolError for disabled resource', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const resource = mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + resource.disable(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'resources/read', + params: { + uri: 'test://resource' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('disabled') + }); + }); + + /*** + * Test: Registering a resource template without a complete callback should not update server capabilities to advertise support for completion + */ + test('should not advertise support for completion when a resource template without a complete callback is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined + }), + {}, + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).not.toHaveProperty('completions'); + }); + + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a resource template with a complete callback is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + {}, + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); + }); + + /*** + * Test: Resource Template Parameter Completion + */ + test('should support completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + {}, + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: '' + } + } + }); + + expect(result.completion.values).toEqual(['books', 'movies', 'music']); + expect(result.completion.total).toBe(3); + }); + + /*** + * Test: Filtered Resource Template Parameter Completion + */ + test('should support filtered completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) + } + }), + {}, + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: 'm' + } + } + }); + + expect(result.completion.values).toEqual(['movies', 'music']); + expect(result.completion.total).toBe(2); + }); + + /*** + * Test: Pass Request ID to Resource Callback + */ + test('should pass requestId to resource callback via ServerContext', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.registerResource('request-id-test', 'test://resource', {}, async (_uri, ctx) => { + receivedRequestId = ctx.mcpReq.id; + return { + contents: [ + { + uri: 'test://resource', + text: `Received request ID: ${ctx.mcpReq.id}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/read', + params: { + uri: 'test://resource' + } + }); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining(`Received request ID:`), + uri: 'test://resource' + } + ]) + ); + }); + }); + + describe('prompt()', () => { + /*** + * Test: Zero-Argument Prompt Registration + */ + test('should register zero-argument prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt('test', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/list' + }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.arguments).toBeUndefined(); + }); + /*** + * Test: Updating Existing Prompt + */ + test('should update existing prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.registerPrompt('test', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Initial response' + } + } + ] + })); + + // Update the prompt + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the prompt and verify we get the updated response + const result = await client.request({ + method: 'prompts/get', + params: { + name: 'test' + } + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Updating Prompt with Schema + */ + test('should update prompt with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.registerPrompt( + 'test', + { + argsSchema: z.object({ + name: z.string() + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Initial: ${name}` + } + } + ] + }) + ); + + // Update the prompt with a different schema + prompt.update({ + argsSchema: z.object({ + name: z.string(), + value: z.string() + }), + callback: async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Updated: ${name}, ${value}` + } + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify the schema was updated + const listResult = await client.request({ + method: 'prompts/list' + }); + + expect(listResult.prompts[0]!.arguments).toHaveLength(2); + expect(listResult.prompts[0]!.arguments!.map(a => a.name).toSorted()).toEqual(['name', 'value']); + + // Call the prompt with the new schema + const getResult = await client.request({ + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'value' + } + } + }); + + expect(getResult.messages).toHaveLength(1); + expect(getResult.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated: test, value' + } + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Prompt List Changed Notification + */ + test('should send prompt list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.registerPrompt('test', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the prompt while connected + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + }); + + /*** + * Test: Remove Prompt and Send Notification + */ + test('should remove prompt and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompts + const prompt1 = mcpServer.registerPrompt('prompt1', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Prompt 1 response' + } + } + ] + })); + + mcpServer.registerPrompt('prompt2', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Prompt 2 response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify both prompts are registered + let result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(2); + expect(result.prompts.map(p => p.name).toSorted()).toEqual(['prompt1', 'prompt2']); + + expect(notifications).toHaveLength(0); + + // Remove a prompt + prompt1.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + + // Verify the prompt was removed + result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('prompt2'); + }); + + /*** + * Test: Prompt Registration with Arguments Schema + */ + test('should register prompt with args schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test', + { + argsSchema: z.object({ + name: z.string(), + value: z.string() + }) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/list' + }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.arguments).toEqual([ + { name: 'name', required: true }, + { name: 'value', required: true } + ]); + }); + + /*** + * Test: Prompt Registration with Description + */ + test('should register prompt with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt('test', { description: 'Test description' }, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/list' + }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.description).toBe('Test description'); + }); + + /*** + * Test: Prompt Argument Validation + */ + test('should validate prompt args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test', + { + argsSchema: z.object({ + name: z.string(), + value: z.string().min(3) + }) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'ab' // Too short + } + } + }) + ).rejects.toThrow(/Invalid arguments/); + }); + + /*** + * Test: Preventing Duplicate Prompt Registration + */ + test('should prevent duplicate prompt registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.registerPrompt('test', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + expect(() => { + mcpServer.registerPrompt('test', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Prompt Registration + */ + test('should allow registering multiple prompts', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.registerPrompt('prompt1', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 1' + } + } + ] + })); + + // This should also succeed and not throw about request handlers + mcpServer.registerPrompt('prompt2', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }); + + /*** + * Test: Prompt Registration with Arguments + */ + test('should allow registering prompts with arguments', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.registerPrompt('echo', { argsSchema: z.object({ message: z.string() }) }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` + } + } + ] + })); + }); + + /*** + * Test: Resources and Prompts with Completion Handlers + */ + test('should allow registering both resources and prompts with completion handlers', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Register a resource with completion + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + {}, + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + // Register a prompt with completion + mcpServer.registerPrompt( + 'echo', + { argsSchema: z.object({ message: completable(z.string(), () => ['hello', 'world']) }) }, + ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` + } + } + ] + }) + ); + }); + + /*** + * Test: ProtocolError for Invalid Prompt Name + */ + test('should throw ProtocolError for invalid prompt name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt('test-prompt', {}, async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'prompts/get', + params: { + name: 'nonexistent-prompt' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('not found') + }); + }); + + /*** + * Test: Registering a prompt without a completable argument should not update server capabilities to advertise support for completion + */ + test('should not advertise support for completion when a prompt without a completable argument is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: z.string() + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const capabilities = client.getServerCapabilities() || {}; + const keys = Object.keys(capabilities); + expect(keys).not.toContain('completions'); + }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a prompt with a completable argument is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); + }); + + /*** + * Test: Prompt Argument Completion + */ + test('should support completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: '' + } + } + }); + + expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.completion.total).toBe(3); + }); + + /*** + * Test: Filtered Prompt Argument Completion + */ + test('should support filtered completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) + }) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + } + } + }); + + expect(result.completion.values).toEqual(['Alice']); + expect(result.completion.total).toBe(1); + }); + + /*** + * Test: Pass Request ID to Prompt Callback + */ + test('should pass requestId to prompt callback via ServerContext', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.registerPrompt('request-id-test', {}, async ctx => { + receivedRequestId = ctx.mcpReq.id; + return { + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Received request ID: ${ctx.mcpReq.id}` + } + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/get', + params: { + name: 'request-id-test' + } + }); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: expect.stringContaining(`Received request ID:`) + } + } + ]) + ); + }); + + /*** + * Test: Resource Template Metadata Priority + */ + test('should prioritize individual resource metadata over template metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1', + description: 'Individual resource description', + mimeType: 'text/plain' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + // This resource has no description or mimeType + } + ] + }) + }), + { + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0]!.name).toBe('Resource 1'); + expect(result.resources[0]!.description).toBe('Individual resource description'); + expect(result.resources[0]!.mimeType).toBe('text/plain'); + + // Resource 2 should inherit template metadata + expect(result.resources[1]!.name).toBe('Resource 2'); + expect(result.resources[1]!.description).toBe('Template description'); + expect(result.resources[1]!.mimeType).toBe('application/json'); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test('should allow resource to override all template metadata fields', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Overridden Name', + uri: 'test://resource/1', + description: 'Overridden description', + mimeType: 'text/markdown' + // Add any other metadata fields if they exist + } + ] + }) + }), + { + title: 'Template Name', + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0]!.name).toBe('Overridden Name'); + expect(result.resources[0]!.description).toBe('Overridden description'); + expect(result.resources[0]!.mimeType).toBe('text/markdown'); + }); + + test('should support optional prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + argsSchema: z.object({ + name: z.string().optional() + }) + }, + () => ({ + messages: [] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/list' + }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test-prompt'); + expect(result.prompts[0]!.arguments).toEqual([ + { + name: 'name', + description: undefined, + required: false + } + ]); + }); + + /*** + * Test: Prompt Registration with _meta field + */ + test('should register prompt with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; + + mcpServer.registerPrompt( + 'test-with-meta', + { + description: 'A prompt with _meta field', + _meta: metaData + }, + async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test-with-meta'); + expect(result.prompts[0]!.description).toBe('A prompt with _meta field'); + expect(result.prompts[0]!._meta).toEqual(metaData); + }); + + /*** + * Test: Prompt Registration without _meta field should have undefined _meta + */ + test('should register prompt without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-without-meta', + { + description: 'A prompt without _meta field' + }, + async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'prompts/list' }); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0]!.name).toBe('test-without-meta'); + expect(result.prompts[0]!._meta).toBeUndefined(); + }); + }); + + describe('Tool title precedence', () => { + test('should follow correct title precedence: title → annotations.title → name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Tool 1: Only name + mcpServer.registerTool('tool_name_only', {}, async () => ({ + content: [{ type: 'text', text: 'Response' }] + })); + + // Tool 2: Name and annotations.title + mcpServer.registerTool( + 'tool_with_annotations_title', + { + description: 'Tool with annotations title', + annotations: { + title: 'Annotations Title' + } + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + 'tool_with_title', + { + title: 'Regular Title', + description: 'Tool with regular title' + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + 'tool_with_all_titles', + { + title: 'Regular Title Wins', + description: 'Tool with all titles', + annotations: { + title: 'Annotations Title Should Not Show' + } + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === 'tool_name_only'); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe('tool_name_only'); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe('Annotations Title'); + expect(getDisplayName(tool2!)).toBe('Annotations Title'); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === 'tool_with_title'); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe('Regular Title'); + expect(getDisplayName(tool3!)).toBe('Regular Title'); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe('Regular Title Wins'); + expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); + expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); + }); + + test('getDisplayName unit tests for title precedence', () => { + // Test 1: Only name + expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); + + // Test 2: Name and title - title wins + expect( + getDisplayName({ + name: 'tool_name', + title: 'Tool Title' + }) + ).toBe('Tool Title'); + + // Test 3: Name and annotations.title - annotations.title wins + expect( + getDisplayName({ + name: 'tool_name', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 4: All three - title wins (correct precedence) + expect( + getDisplayName({ + name: 'tool_name', + title: 'Regular Title', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Regular Title'); + + // Test 5: Empty title should not be used + expect( + getDisplayName({ + name: 'tool_name', + title: '', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 6: Undefined vs null handling + expect( + getDisplayName({ + name: 'tool_name', + title: undefined, + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + }); + + test('should support resource template completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('github://repos/{owner}/{repo}', { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.['owner'] === 'org1') { + return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); + } else if (context?.arguments?.['owner'] === 'org2') { + return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + } + return []; + } + } + }), + { + title: 'GitHub Repository', + description: 'Repository information' + }, + async () => ({ + contents: [ + { + uri: 'github://repos/test/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with microsoft owner + const result1 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'p' + }, + context: { + arguments: { + owner: 'org1' + } + } + } + }); + + expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'r' + }, + context: { + arguments: { + owner: 'org2' + } + } + } + }); + + expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 't' + } + } + }); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test('should support prompt argument completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + title: 'Team Greeting', + description: 'Generate a greeting for team members', + argsSchema: z.object({ + department: completable(z.string(), value => { + return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + const department = context?.arguments?.['department']; + switch (department) { + case 'engineering': { + return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); + } + case 'sales': { + return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); + } + case 'marketing': { + return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); + } + // No default + } + return ['Guest'].filter(n => n.startsWith(value)); + }) + }) + }, + async ({ department, name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}, welcome to the ${department} team!` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with engineering department + const result1 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + }, + context: { + arguments: { + department: 'engineering' + } + } + } + }); + + expect(result1.completion.values).toEqual(['Alice']); + + // Test with sales department + const result2 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'D' + }, + context: { + arguments: { + department: 'sales' + } + } + } + }); + + expect(result2.completion.values).toEqual(['David']); + + // Test with marketing department + const result3 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + }, + context: { + arguments: { + department: 'marketing' + } + } + } + }); + + expect(result3.completion.values).toEqual(['Grace']); + + // Test with no resolved context + const result4 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + } + } + }); + + expect(result4.completion.values).toEqual(['Guest']); + }); + }); + + describe('elicitInput()', () => { + const checkAvailability = vi.fn().mockResolvedValue(false); + const findAlternatives = vi.fn().mockResolvedValue([]); + const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0' + }); + + // Register the restaurant booking tool from README example + mcpServer.registerTool( + 'book-restaurant', + { + inputSchema: z.object({ + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }) + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: 'object', + properties: { + checkAlternatives: { + type: 'boolean', + title: 'Check alternative dates', + description: 'Would you like me to check other dates?' + }, + flexibleDates: { + type: 'string', + title: 'Date flexibility', + description: 'How flexible are your dates?', + enum: ['next_day', 'same_week', 'next_week'], + enumNames: ['Next day', 'Same week', 'Next week'] + } + }, + required: ['checkAlternatives'] + } + }); + + if (result.action === 'accept' && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [ + { + type: 'text', + text: `Found these alternatives: ${alternatives.join(', ')}` + } + ] + }; + } + + return { + content: [ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ] + }; + } + + await makeBooking(restaurant, date, partySize); + return { + content: [ + { + type: 'text', + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + } + ] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + }); + + test('should successfully elicit additional information', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); + + // Set up client to accept alternative date checking + client.setRequestHandler('elicitation/create', async request => { + expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); + return { + action: 'accept', + content: { + checkAlternatives: true, + flexibleDates: 'same_week' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' + } + ]); + }); + + test('should handle user declining to elicitation request', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to reject alternative date checking + client.setRequestHandler('elicitation/create', async () => { + return { + action: 'accept', + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); + + test('should handle user cancelling the elicitation', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler('elicitation/create', async () => { + return { + action: 'cancel' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); + }); + + describe('Tools with union and intersection schemas', () => { + test('should support union schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('email'), email: z.string().email() }), + z.object({ type: z.literal('phone'), phone: z.string() }) + ]); + + server.registerTool('contact', { inputSchema: unionSchema }, async args => { + return args.type === 'email' + ? { + content: [{ type: 'text' as const, text: `Email contact: ${args.email}` }] + } + : { + content: [{ type: 'text' as const, text: `Phone contact: ${args.phone}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const emailResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'email', + email: 'test@example.com' + } + }); + + expect(emailResult.content).toEqual([ + { + type: 'text', + text: 'Email contact: test@example.com' + } + ]); + + const phoneResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'phone', + phone: '+1234567890' + } + }); + + expect(phoneResult.content).toEqual([ + { + type: 'text', + text: 'Phone contact: +1234567890' + } + ]); + }); + + test('should support intersection schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const baseSchema = z.object({ id: z.string() }); + const extendedSchema = z.object({ name: z.string(), age: z.number() }); + const intersectionSchema = z.intersection(baseSchema, extendedSchema); + + server.registerTool('user', { inputSchema: intersectionSchema }, async args => { + return { + content: [ + { + type: 'text', + text: `User: ${args.id}, ${args.name}, ${args.age} years old` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'user', + arguments: { + id: '123', + name: 'John Doe', + age: 30 + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'User: 123, John Doe, 30 years old' + } + ]); + }); + + test('should support complex nested schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const schema = z.object({ + items: z.array( + z.union([ + z.object({ type: z.literal('text'), content: z.string() }), + z.object({ type: z.literal('number'), value: z.number() }) + ]) + ) + }); + + server.registerTool('process', { inputSchema: schema }, async args => { + const processed = args.items.map(item => { + return item.type === 'text' ? item.content.toUpperCase() : item.value * 2; + }); + return { + content: [ + { + type: 'text', + text: `Processed: ${processed.join(', ')}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'process', + arguments: { + items: [ + { type: 'text', content: 'hello' }, + { type: 'number', value: 5 }, + { type: 'text', content: 'world' } + ] + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: HELLO, 10, WORLD' + } + ]); + }); + + test('should validate union schema inputs correctly', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('a'), value: z.string() }), + z.object({ type: z.literal('b'), value: z.number() }) + ]); + + server.registerTool('union-test', { inputSchema: unionSchema }, async () => { + return { + content: [{ type: 'text' as const, text: 'Success' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const invalidTypeResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }); + + expect(invalidTypeResult.isError).toBe(true); + expect(invalidTypeResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); + }); + }); + + describe('Tools with transformation schemas', () => { + test('should support z.preprocess() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.preprocess() allows transforming input before validation + const preprocessSchema = z.preprocess( + input => { + // Normalize input by trimming strings + if (typeof input === 'object' && input !== null) { + const obj = input as Record; + if (typeof obj.name === 'string') { + return { ...obj, name: obj.name.trim() }; + } + } + return input; + }, + z.object({ name: z.string() }) + ); + + server.registerTool('preprocess-test', { inputSchema: preprocessSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Hello, ${args.name}!` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Test with input that has leading/trailing whitespace + const result = await client.callTool({ + name: 'preprocess-test', + arguments: { name: ' World ' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Hello, World!' + } + ]); + }); + + test('should support z.transform() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.transform() allows transforming validated output + const transformSchema = z + .object({ + firstName: z.string(), + lastName: z.string() + }) + .transform(data => ({ + ...data, + fullName: `${data.firstName} ${data.lastName}` + })); + + server.registerTool('transform-test', { inputSchema: transformSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Full name: ${args.fullName}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'transform-test', + arguments: { firstName: 'John', lastName: 'Doe' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Full name: John Doe' + } + ]); + }); + + test('should support z.pipe() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.pipe() chains multiple schemas together + const pipeSchema = z + .object({ value: z.string() }) + .transform(data => ({ ...data, processed: true })) + .pipe(z.object({ value: z.string(), processed: z.boolean() })); + + server.registerTool('pipe-test', { inputSchema: pipeSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Value: ${args.value}, Processed: ${args.processed}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'pipe-test', + arguments: { value: 'test' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Value: test, Processed: true' + } + ]); + }); + + test('should support nested transformation schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // Complex schema with both preprocess and transform + const complexSchema = z.preprocess( + input => { + if (typeof input === 'object' && input !== null) { + const obj = input as Record; + // Convert string numbers to actual numbers + if (typeof obj.count === 'string') { + return { ...obj, count: Number.parseInt(obj.count, 10) }; + } + } + return input; + }, + z + .object({ + name: z.string(), + count: z.number() + }) + .transform(data => ({ + ...data, + doubled: data.count * 2 + })) + ); + + server.registerTool('complex-transform', { inputSchema: complexSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `${args.name}: ${args.count} -> ${args.doubled}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Pass count as string, preprocess will convert it + const result = await client.callTool({ + name: 'complex-transform', + arguments: { name: 'items', count: '5' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'items: 5 -> 10' + } + ]); + }); + }); + + describe('resource()', () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ + test('should register resource with uri and readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.name).toBe('test'); + expect(result.resources[0]!.uri).toBe('test://resource'); + }); + + /*** + * Test: Update Resource with URI + */ + test('should update resource with uri', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.registerResource('test', 'test://resource', {}, async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Initial content' + } + ] + })); + + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + // Updates before connection should not trigger notifications + expect(notifications).toHaveLength(0); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/read', + params: { + uri: 'test://resource' + } + }); + + expect(result.contents).toEqual([ + { + uri: 'test://resource', + text: 'Updated content' + } + ]); + + // Now update again after connection + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Another update' + } + ] + }) + }); + + // Yield to event loop for notification to fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + }); + + /*** + * Test: Resource Template Metadata Priority + */ + test('should prioritize individual resource metadata over template metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1', + description: 'Individual resource description', + mimeType: 'text/plain' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + // This resource has no description or mimeType + } + ] + }) + }), + { + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0]!.name).toBe('Resource 1'); + expect(result.resources[0]!.description).toBe('Individual resource description'); + expect(result.resources[0]!.mimeType).toBe('text/plain'); + + // Resource 2 should inherit template metadata + expect(result.resources[1]!.name).toBe('Resource 2'); + expect(result.resources[1]!.description).toBe('Template description'); + expect(result.resources[1]!.mimeType).toBe('application/json'); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test('should allow resource to override all template metadata fields', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Overridden Name', + uri: 'test://resource/1', + description: 'Overridden description', + mimeType: 'text/markdown' + // Add any other metadata fields if they exist + } + ] + }) + }), + { + title: 'Template Name', + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'resources/list' + }); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0]!.name).toBe('Overridden Name'); + expect(result.resources[0]!.description).toBe('Overridden description'); + expect(result.resources[0]!.mimeType).toBe('text/markdown'); + }); + }); + + describe('Tool title precedence', () => { + test('should follow correct title precedence: title → annotations.title → name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Tool 1: Only name + mcpServer.registerTool('tool_name_only', {}, async () => ({ + content: [{ type: 'text', text: 'Response' }] + })); + + // Tool 2: Name and annotations.title + mcpServer.registerTool( + 'tool_with_annotations_title', + { + description: 'Tool with annotations title', + annotations: { + title: 'Annotations Title' + } + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + 'tool_with_title', + { + title: 'Regular Title', + description: 'Tool with regular title' + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + 'tool_with_all_titles', + { + title: 'Regular Title Wins', + description: 'Tool with all titles', + annotations: { + title: 'Annotations Title Should Not Show' + } + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(4); + + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === 'tool_name_only'); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe('tool_name_only'); + + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe('Annotations Title'); + expect(getDisplayName(tool2!)).toBe('Annotations Title'); + + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === 'tool_with_title'); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe('Regular Title'); + expect(getDisplayName(tool3!)).toBe('Regular Title'); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe('Regular Title Wins'); + expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); + expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); + }); + + test('getDisplayName unit tests for title precedence', () => { + // Test 1: Only name + expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); + + // Test 2: Name and title - title wins + expect( + getDisplayName({ + name: 'tool_name', + title: 'Tool Title' + }) + ).toBe('Tool Title'); + + // Test 3: Name and annotations.title - annotations.title wins + expect( + getDisplayName({ + name: 'tool_name', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 4: All three - title wins (correct precedence) + expect( + getDisplayName({ + name: 'tool_name', + title: 'Regular Title', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Regular Title'); + + // Test 5: Empty title should not be used + expect( + getDisplayName({ + name: 'tool_name', + title: '', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 6: Undefined vs null handling + expect( + getDisplayName({ + name: 'tool_name', + title: undefined, + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + }); + + test('should support resource template completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('github://repos/{owner}/{repo}', { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.['owner'] === 'org1') { + return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); + } else if (context?.arguments?.['owner'] === 'org2') { + return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + } + return []; + } + } + }), + { + title: 'GitHub Repository', + description: 'Repository information' + }, + async () => ({ + contents: [ + { + uri: 'github://repos/test/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with microsoft owner + const result1 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'p' + }, + context: { + arguments: { + owner: 'org1' + } + } + } + }); + + expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'r' + }, + context: { + arguments: { + owner: 'org2' + } + } + } + }); + + expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 't' + } + } + }); + + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test('should support prompt argument completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + title: 'Team Greeting', + description: 'Generate a greeting for team members', + argsSchema: z.object({ + department: completable(z.string(), value => { + return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + const department = context?.arguments?.['department']; + switch (department) { + case 'engineering': { + return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); + } + case 'sales': { + return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); + } + case 'marketing': { + return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); + } + // No default + } + return ['Guest'].filter(n => n.startsWith(value)); + }) + }) + }, + async ({ department, name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}, welcome to the ${department} team!` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with engineering department + const result1 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + }, + context: { + arguments: { + department: 'engineering' + } + } + } + }); + + expect(result1.completion.values).toEqual(['Alice']); + + // Test with sales department + const result2 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'D' + }, + context: { + arguments: { + department: 'sales' + } + } + } + }); + + expect(result2.completion.values).toEqual(['David']); + + // Test with marketing department + const result3 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + }, + context: { + arguments: { + department: 'marketing' + } + } + } + }); + + expect(result3.completion.values).toEqual(['Grace']); + + // Test with no resolved context + const result4 = await client.request({ + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + } + } + }); + + expect(result4.completion.values).toEqual(['Guest']); + }); + }); + + describe('elicitInput()', () => { + const checkAvailability = vi.fn().mockResolvedValue(false); + const findAlternatives = vi.fn().mockResolvedValue([]); + const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0' + }); + + // Register the restaurant booking tool from README example + mcpServer.registerTool( + 'book-restaurant', + { + inputSchema: z.object({ + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }) + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: 'object', + properties: { + checkAlternatives: { + type: 'boolean', + title: 'Check alternative dates', + description: 'Would you like me to check other dates?' + }, + flexibleDates: { + type: 'string', + title: 'Date flexibility', + description: 'How flexible are your dates?', + enum: ['next_day', 'same_week', 'next_week'], + enumNames: ['Next day', 'Same week', 'Next week'] + } + }, + required: ['checkAlternatives'] + } + }); + + if (result.action === 'accept' && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [ + { + type: 'text', + text: `Found these alternatives: ${alternatives.join(', ')}` + } + ] + }; + } + + return { + content: [ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ] + }; + } + + await makeBooking(restaurant, date, partySize); + return { + content: [ + { + type: 'text', + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + } + ] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + }); + + test('should successfully elicit additional information', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); + + // Set up client to accept alternative date checking + client.setRequestHandler('elicitation/create', async request => { + expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); + return { + action: 'accept', + content: { + checkAlternatives: true, + flexibleDates: 'same_week' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' + } + ]); + }); + + test('should handle user declining to elicitation request', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to reject alternative date checking + client.setRequestHandler('elicitation/create', async () => { + return { + action: 'accept', + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); + + test('should handle user cancelling the elicitation', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler('elicitation/create', async () => { + return { + action: 'cancel' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); + }); + + describe('Tools with union and intersection schemas', () => { + test('should support union schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('email'), email: z.string().email() }), + z.object({ type: z.literal('phone'), phone: z.string() }) + ]); + + server.registerTool('contact', { inputSchema: unionSchema }, async args => { + return args.type === 'email' + ? { + content: [{ type: 'text', text: `Email contact: ${args.email}` }] + } + : { + content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const emailResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'email', + email: 'test@example.com' + } + }); + + expect(emailResult.content).toEqual([ + { + type: 'text', + text: 'Email contact: test@example.com' + } + ]); + + const phoneResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'phone', + phone: '+1234567890' + } + }); + + expect(phoneResult.content).toEqual([ + { + type: 'text', + text: 'Phone contact: +1234567890' + } + ]); + }); + + test('should support intersection schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const baseSchema = z.object({ id: z.string() }); + const extendedSchema = z.object({ name: z.string(), age: z.number() }); + const intersectionSchema = z.intersection(baseSchema, extendedSchema); + + server.registerTool('user', { inputSchema: intersectionSchema }, async args => { + return { + content: [ + { + type: 'text', + text: `User: ${args.id}, ${args.name}, ${args.age} years old` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'user', + arguments: { + id: '123', + name: 'John Doe', + age: 30 + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'User: 123, John Doe, 30 years old' + } + ]); + }); + + test('should support complex nested schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const schema = z.object({ + items: z.array( + z.union([ + z.object({ type: z.literal('text'), content: z.string() }), + z.object({ type: z.literal('number'), value: z.number() }) + ]) + ) + }); + + server.registerTool('process', { inputSchema: schema }, async args => { + const processed = args.items.map(item => { + return item.type === 'text' ? item.content.toUpperCase() : item.value * 2; + }); + return { + content: [ + { + type: 'text', + text: `Processed: ${processed.join(', ')}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'process', + arguments: { + items: [ + { type: 'text', content: 'hello' }, + { type: 'number', value: 5 }, + { type: 'text', content: 'world' } + ] + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: HELLO, 10, WORLD' + } + ]); + }); + + test('should validate union schema inputs correctly', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('a'), value: z.string() }), + z.object({ type: z.literal('b'), value: z.number() }) + ]); + + server.registerTool('union-test', { inputSchema: unionSchema }, async () => { + return { + content: [{ type: 'text', text: 'Success' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const invalidTypeResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }); + + expect(invalidTypeResult.isError).toBe(true); + expect(invalidTypeResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); + + const invalidDiscriminatorResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'c', + value: 'test' + } + }); + + expect(invalidDiscriminatorResult.isError).toBe(true); + expect(invalidDiscriminatorResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); + }); + }); + + describe('Tool-level task hints with automatic polling wrapper', () => { + test('should return error for tool with taskSupport "required" called without task augmentation', async () => { + const taskStore = new InMemoryTaskStore(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + // Register a task-based tool with taskSupport "required" + mcpServer.experimental.tasks.registerToolTask( + 'long-running-task', + { + description: 'A long running task', + inputSchema: z.object({ + input: z.string() + }), + execution: { + taskSupport: 'required' + } + }, + { + createTask: async ({ input }, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + + // Capture taskStore for use in setTimeout + const store = ctx.task.store; + + // Simulate async work + setTimeout(async () => { + await store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text' as const, text: `Processed: ${input}` }] + }); + }, 200); + + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async (_input, ctx) => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool WITHOUT task augmentation - should return error + const result = await client.callTool({ + name: 'long-running-task', + arguments: { input: 'test data' } + }); + + // Should receive error result + expect(result.isError).toBe(true); + const content = result.content as TextContent[]; + expect(content[0]!.text).toContain('requires task augmentation'); + + taskStore.cleanup(); + }); + + test('should automatically poll and return CallToolResult for tool with taskSupport "optional" called without task augmentation', async () => { + const taskStore = new InMemoryTaskStore(); + const { releaseLatch, waitForLatch } = createLatch(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + // Register a task-based tool with taskSupport "optional" + mcpServer.experimental.tasks.registerToolTask( + 'optional-task', + { + description: 'An optional task', + inputSchema: z.object({ + value: z.number() + }), + execution: { + taskSupport: 'optional' + } + }, + { + createTask: async ({ value }, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + + // Capture taskStore for use in setTimeout + const store = ctx.task.store; + + // Simulate async work + setTimeout(async () => { + await store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text' as const, text: `Result: ${value * 2}` }] + }); + releaseLatch(); + }, 150); + + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async (_value, ctx) => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool WITHOUT task augmentation + const result = await client.callTool({ + name: 'optional-task', + arguments: { value: 21 } + }); + + // Should receive CallToolResult directly, not CreateTaskResult + expect(result).toHaveProperty('content'); + expect(result.content).toEqual([{ type: 'text' as const, text: 'Result: 42' }]); + expect(result).not.toHaveProperty('task'); + + // Wait for async operations to complete + await waitForLatch(); + taskStore.cleanup(); + }); + + test('should return CreateTaskResult when tool with taskSupport "required" is called WITH task augmentation', async () => { + const taskStore = new InMemoryTaskStore(); + const { releaseLatch, waitForLatch } = createLatch(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + // Register a task-based tool with taskSupport "required" + mcpServer.experimental.tasks.registerToolTask( + 'task-tool', + { + description: 'A task tool', + inputSchema: z.object({ + data: z.string() + }), + execution: { + taskSupport: 'required' + } + }, + { + createTask: async ({ data }, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + + // Capture taskStore for use in setTimeout + const store = ctx.task.store; + + // Simulate async work + setTimeout(async () => { + await store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text' as const, text: `Completed: ${data}` }] + }); + releaseLatch(); + }, 200); + + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async (_data, ctx) => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool WITH task augmentation + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'task-tool', + arguments: { data: 'test' }, + task: { ttl: 60_000 } + } + }, + z.object({ + task: z.object({ + taskId: z.string(), + status: z.string(), + ttl: z.union([z.number(), z.null()]), + createdAt: z.string(), + pollInterval: z.number().optional() + }) + }) + ); + + // Should receive CreateTaskResult with task field + expect(result).toHaveProperty('task'); + expect(result.task).toHaveProperty('taskId'); + expect(result.task.status).toBe('working'); + + // Wait for async operations to complete + await waitForLatch(); + taskStore.cleanup(); + }); + + test('should handle task failures during automatic polling', async () => { + const taskStore = new InMemoryTaskStore(); + const { releaseLatch, waitForLatch } = createLatch(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + // Register a task-based tool that fails + mcpServer.experimental.tasks.registerToolTask( + 'failing-task', + { + description: 'A failing task', + execution: { + taskSupport: 'optional' + } + }, + { + createTask: async ctx => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + + // Capture taskStore for use in setTimeout + const store = ctx.task.store; + + // Simulate async failure + setTimeout(async () => { + await store.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text' as const, text: 'Error occurred' }], + isError: true + }); + releaseLatch(); + }, 150); + + return { task }; + }, + getTask: async ctx => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async ctx => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool WITHOUT task augmentation + const result = await client.callTool({ + name: 'failing-task', + arguments: {} + }); + + // Should receive the error result + expect(result).toHaveProperty('content'); + expect(result.content).toEqual([{ type: 'text' as const, text: 'Error occurred' }]); + expect(result.isError).toBe(true); + + // Wait for async operations to complete + await waitForLatch(); + taskStore.cleanup(); + }); + + test('should handle task cancellation during automatic polling', async () => { + const taskStore = new InMemoryTaskStore(); + const { releaseLatch, waitForLatch } = createLatch(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + } + } + } + } + ); + + // Register a task-based tool that gets cancelled + mcpServer.experimental.tasks.registerToolTask( + 'cancelled-task', + { + description: 'A task that gets cancelled', + execution: { + taskSupport: 'optional' + } + }, + { + createTask: async ctx => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + + // Capture taskStore for use in setTimeout + const store = ctx.task.store; + + // Simulate async cancellation + setTimeout(async () => { + await store.updateTaskStatus(task.taskId, 'cancelled', 'Task was cancelled'); + releaseLatch(); + }, 150); + + return { task }; + }, + getTask: async ctx => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async ctx => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool WITHOUT task augmentation + const result = await client.callTool({ + name: 'cancelled-task', + arguments: {} + }); + + // Should receive an error since cancelled tasks don't have results + expect(result).toHaveProperty('content'); + expect(result.content).toEqual([{ type: 'text' as const, text: expect.stringContaining('has no result stored') }]); + + // Wait for async operations to complete + await waitForLatch(); + taskStore.cleanup(); + }); + + test('should raise error when registerToolTask is called with taskSupport "forbidden"', () => { + const taskStore = new InMemoryTaskStore(); + + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + tools: {}, + tasks: { + requests: { + tools: { + call: {} + } + }, + + taskStore + } + } + } + ); + + // Attempt to register a task-based tool with taskSupport "forbidden" (cast to bypass type checking) + expect(() => { + mcpServer.experimental.tasks.registerToolTask( + 'invalid-task', + { + description: 'A task with forbidden support', + inputSchema: z.object({ + input: z.string() + }), + execution: { + taskSupport: 'forbidden' as unknown as 'required' + } + }, + { + createTask: async (_args, ctx) => { + const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 }); + return { task }; + }, + getTask: async (_args, ctx) => { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error('Task not found'); + } + return task; + }, + getTaskResult: async (_args, ctx) => { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as CallToolResult; + } + } + ); + }).toThrow(); + + taskStore.cleanup(); + }); + }); +}); diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts new file mode 100644 index 0000000..67f16c5 --- /dev/null +++ b/test/integration/test/standardSchema.test.ts @@ -0,0 +1,754 @@ +/** + * Integration tests for Standard Schema support (StandardJSONSchemaV1) + * Tests ArkType and Valibot schemas with the MCP SDK + */ + +import { Client } from '@modelcontextprotocol/client'; +import type { TextContent } from '@modelcontextprotocol/core'; +import { AjvJsonSchemaValidator, fromJsonSchema, InMemoryTransport } from '@modelcontextprotocol/core'; +import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server'; +import { toStandardJsonSchema } from '@valibot/to-json-schema'; +import { type } from 'arktype'; +import * as v from 'valibot'; +import { beforeEach, describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; + +describe('Standard Schema Support', () => { + let mcpServer: McpServer; + let client: Client; + + beforeEach(async () => { + mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + client = new Client({ + name: 'test client', + version: '1.0' + }); + }); + + async function connectClientAndServer() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + } + + describe('ArkType schemas', () => { + describe('tool registration', () => { + test('should register tool with ArkType input schema', async () => { + const inputSchema = type({ + name: 'string', + age: 'number' + }); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet a person', + inputSchema + }, + async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, you are ${age} years old` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('greet'); + expect(result.tools[0].inputSchema).toMatchObject({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }); + // Check required array contains both fields (order may vary by library) + expect(result.tools[0].inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); + }); + + test('should register tool with ArkType input and output schemas', async () => { + const inputSchema = type({ x: 'number', y: 'number' }); + const outputSchema = type({ result: 'number', operation: 'string' }); + + mcpServer.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema, + outputSchema + }, + async ({ x, y }) => ({ + content: [{ type: 'text', text: `${x + y}` }], + structuredContent: { result: x + y, operation: 'addition' } + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools[0].outputSchema).toMatchObject({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + result: { type: 'number' }, + operation: { type: 'string' } + } + }); + expect(result.tools[0].outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); + }); + }); + + describe('tool validation', () => { + test('should validate valid input and execute tool', async () => { + const inputSchema = type({ value: 'number' }); + + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 21 } } + }); + + expect(result.content[0]).toEqual({ type: 'text', text: '42' }); + }); + + test('should return validation error for invalid input type', async () => { + const inputSchema = type({ value: 'number' }); + + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('value'); + expect(errorText).toContain('number'); + }); + + test('should return validation error for invalid enum value', async () => { + const inputSchema = type({ + operation: "'add' | 'subtract' | 'multiply'" + }); + + mcpServer.registerTool('calculate', { inputSchema }, async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toMatch(/add|subtract|multiply/); + }); + + test('should return validation error for missing required field', async () => { + const inputSchema = type({ name: 'string', age: 'number' }); + + mcpServer.registerTool('greet', { inputSchema }, async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, ${age}` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Alice' } } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('age'); + }); + }); + }); + + describe('Valibot schemas', () => { + describe('tool registration', () => { + test('should register tool with Valibot input schema', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + name: v.string(), + age: v.number() + }) + ); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet a person', + inputSchema + }, + async ({ name, age }) => ({ + content: [{ type: 'text', text: `Hello ${name}, you are ${age} years old` }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('greet'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + }, + required: ['name', 'age'] + }); + }); + + test('should register tool with Valibot schema with descriptions', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + city: v.pipe(v.string(), v.description('The city name')), + country: v.pipe(v.string(), v.description('The country code')) + }) + ); + + mcpServer.registerTool('weather', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'sunny' }] + })); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/list' }); + + expect(result.tools[0].inputSchema.properties).toMatchObject({ + city: { type: 'string', description: 'The city name' }, + country: { type: 'string', description: 'The country code' } + }); + }); + }); + + describe('tool validation', () => { + test('should validate valid input and execute tool', async () => { + const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); + + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 21 } } + }); + + expect(result.content[0]).toEqual({ type: 'text', text: '42' }); + }); + + test('should return validation error for invalid input type', async () => { + const inputSchema = toStandardJsonSchema(v.object({ value: v.number() })); + + mcpServer.registerTool('double', { inputSchema }, async ({ value }) => ({ + content: [{ type: 'text', text: `${value * 2}` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + expect(errorText).toContain('number'); + }); + + test('should return validation error for invalid picklist value', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + operation: v.picklist(['add', 'subtract', 'multiply']) + }) + ); + + mcpServer.registerTool('calculate', { inputSchema }, async ({ operation }) => ({ + content: [{ type: 'text', text: operation }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + + test('should validate min/max constraints', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + percentage: v.pipe(v.number(), v.minValue(0), v.maxValue(100)) + }) + ); + + mcpServer.registerTool('setPercentage', { inputSchema }, async ({ percentage }) => ({ + content: [{ type: 'text', text: `${percentage}%` }] + })); + + await connectClientAndServer(); + + // Valid value + const validResult = await client.request({ + method: 'tools/call', + params: { name: 'setPercentage', arguments: { percentage: 50 } } + }); + expect(validResult.isError).toBeFalsy(); + + // Invalid value (too high) + const invalidResult = await client.request({ + method: 'tools/call', + params: { name: 'setPercentage', arguments: { percentage: 150 } } + }); + expect(invalidResult.isError).toBe(true); + const errorText = (invalidResult.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + }); + }); + + describe('Mixed schema libraries', () => { + test('should support tools with different schema libraries in same server', async () => { + // Zod tool + mcpServer.registerTool('zod-tool', { inputSchema: z.object({ value: z.string() }) }, async ({ value }) => ({ + content: [{ type: 'text', text: `zod: ${value}` }] + })); + + // ArkType tool + mcpServer.registerTool('arktype-tool', { inputSchema: type({ value: 'string' }) }, async ({ value }) => ({ + content: [{ type: 'text', text: `arktype: ${value}` }] + })); + + // Valibot tool + mcpServer.registerTool( + 'valibot-tool', + { inputSchema: toStandardJsonSchema(v.object({ value: v.string() })) }, + async ({ value }) => ({ content: [{ type: 'text', text: `valibot: ${value}` }] }) + ); + + await connectClientAndServer(); + + const tools = await client.request({ method: 'tools/list' }); + expect(tools.tools).toHaveLength(3); + + // Call each tool + const zodResult = await client.request({ method: 'tools/call', params: { name: 'zod-tool', arguments: { value: 'test' } } }); + expect((zodResult.content[0] as TextContent).text).toBe('zod: test'); + + const arktypeResult = await client.request({ + method: 'tools/call', + params: { name: 'arktype-tool', arguments: { value: 'test' } } + }); + expect((arktypeResult.content[0] as TextContent).text).toBe('arktype: test'); + + const valibotResult = await client.request({ + method: 'tools/call', + params: { name: 'valibot-tool', arguments: { value: 'test' } } + }); + expect((valibotResult.content[0] as TextContent).text).toBe('valibot: test'); + }); + }); + + describe('Raw JSON Schema via fromJsonSchema', () => { + const validator = new AjvJsonSchemaValidator(); + + test('should register tool with raw JSON Schema input', async () => { + const inputSchema = fromJsonSchema<{ name: string }>( + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + validator + ); + + mcpServer.registerTool('greet', { inputSchema }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + + await connectClientAndServer(); + + const listed = await client.request({ method: 'tools/list' }); + expect(listed.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + + const result = await client.request({ method: 'tools/call', params: { name: 'greet', arguments: { name: 'World' } } }); + expect((result.content[0] as TextContent).text).toBe('Hello, World!'); + }); + + test('should reject invalid input via AJV validation', async () => { + const inputSchema = fromJsonSchema( + { type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }, + validator + ); + + mcpServer.registerTool('double', { inputSchema }, async args => { + const { count } = args as { count: number }; + return { content: [{ type: 'text', text: `${count * 2}` }] }; + }); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/call', params: { name: 'double', arguments: { count: 'not a number' } } }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + }); + + describe('fromJsonSchema with default validator (server wrapper)', () => { + test('should use runtime-appropriate default validator when none is provided', async () => { + const inputSchema = serverFromJsonSchema<{ name: string }>({ + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + }); + + mcpServer.registerTool('greet-default', { inputSchema }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + + await connectClientAndServer(); + + const result = await client.request({ method: 'tools/call', params: { name: 'greet-default', arguments: { name: 'World' } } }); + expect((result.content[0] as TextContent).text).toBe('Hello, World!'); + }); + + test('should reject invalid input with default validator', async () => { + const inputSchema = serverFromJsonSchema({ type: 'object', properties: { count: { type: 'number' } }, required: ['count'] }); + + mcpServer.registerTool('double-default', { inputSchema }, async args => { + const { count } = args as { count: number }; + return { content: [{ type: 'text', text: `${count * 2}` }] }; + }); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { name: 'double-default', arguments: { count: 'not a number' } } + }); + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + expect(errorText).toContain('Input validation error'); + }); + }); + + describe('Prompt completions with Zod completable', () => { + // Note: completable() is currently Zod-specific + // These tests verify that Zod schemas with completable still work + + test('should support completion with Zod completable schemas', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), value => + ['Alice', 'Bob', 'Charlie'].filter(n => n.toLowerCase().startsWith(value.toLowerCase())) + ) + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name}` } }] + }) + ); + + await connectClientAndServer(); + + // Test completion + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: 'a' } + } + }); + + expect(result.completion.values).toEqual(['Alice']); + }); + + test('should return all completions when prefix is empty', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name}` } }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: '' } + } + }); + + expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.completion.total).toBe(3); + }); + + test('should support completion for optional completable fields', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), value => + ['Alice', 'Bob', 'Charlie'].filter(n => n.toLowerCase().startsWith(value.toLowerCase())) + ).optional() + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name ?? 'there'}` } }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'name', value: 'b' } + } + }); + + expect(result.completion.values).toEqual(['Bob']); + }); + + test('should return empty result for nonexistent argument name', async () => { + mcpServer.registerPrompt( + 'greeting', + { + argsSchema: z.object({ + name: completable(z.string(), () => ['Alice', 'Bob']) + }) + }, + async ({ name }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `Hello ${name}` } }] + }) + ); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'completion/complete', + params: { + ref: { type: 'ref/prompt', name: 'greeting' }, + argument: { name: 'nonexistent', value: '' } + } + }); + + expect(result.completion.values).toEqual([]); + }); + }); + + describe('Error message quality', () => { + test('ArkType should provide descriptive error messages', async () => { + const inputSchema = type({ + email: 'string', + age: 'number', + status: "'active' | 'inactive'" + }); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + // ArkType should mention type mismatches + expect(errorText).toMatch(/email|age|status/i); + }); + + test('Valibot should provide descriptive error messages', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + email: v.string(), + age: v.number(), + status: v.picklist(['active', 'inactive']) + }) + ); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + // Valibot should provide "Invalid type" messages + expect(errorText).toContain('Invalid type'); + }); + + test('Zod should provide descriptive error messages', async () => { + const inputSchema = z.object({ + email: z.string(), + age: z.number(), + status: z.enum(['active', 'inactive']) + }); + + mcpServer.registerTool('test', { inputSchema }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } + } + }); + + expect(result.isError).toBe(true); + const errorText = (result.content[0] as TextContent).text; + + // Check that error mentions the specific issues + expect(errorText).toContain('Input validation error'); + }); + }); + + describe('Type inference', () => { + test('ArkType callback should receive correctly typed arguments', async () => { + const inputSchema = type({ + name: 'string', + count: 'number', + enabled: 'boolean' + }); + + // This test verifies TypeScript compilation succeeds with correct types + mcpServer.registerTool('typed-tool', { inputSchema }, async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + }); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'typed-tool', + arguments: { name: 'test', count: 42, enabled: true } + } + }); + + expect((result.content[0] as TextContent).text).toBe('test: 42, enabled: true'); + }); + + test('Valibot callback should receive correctly typed arguments', async () => { + const inputSchema = toStandardJsonSchema( + v.object({ + name: v.string(), + count: v.number(), + enabled: v.boolean() + }) + ); + + mcpServer.registerTool('typed-tool', { inputSchema }, async ({ name, count, enabled }) => { + // TypeScript should infer these types correctly + const _name: string = name; + const _count: number = count; + const _enabled: boolean = enabled; + + return { + content: [{ type: 'text', text: `${_name}: ${_count}, enabled: ${_enabled}` }] + }; + }); + + await connectClientAndServer(); + + const result = await client.request({ + method: 'tools/call', + params: { + name: 'typed-tool', + arguments: { name: 'test', count: 42, enabled: true } + } + }); + + expect((result.content[0] as TextContent).text).toBe('test: 42, enabled: true'); + }); + }); +}); diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts new file mode 100644 index 0000000..3f32c64 --- /dev/null +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -0,0 +1,320 @@ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import * as z from 'zod/v4'; + +async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { + logging: {}, + tools: {}, + resources: {}, + prompts: {} + } + } + ); + + // Add a simple resource + mcpServer.registerResource('test-resource', '/test', { description: 'A test resource' }, async () => ({ + contents: [ + { + uri: '/test', + text: 'This is a test resource content' + } + ] + })); + + mcpServer.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + } + ] + })); + + mcpServer.registerTool( + 'greet', + { + description: 'A simple greeting tool', + inputSchema: z.object({ + name: z.string().describe('Name to greet').default('World') + }) + }, + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } + ); + + // Create transport with or without session management + const serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: withSessionManagement + ? () => randomUUID() // With session management, generate UUID + : undefined // Without session management, return undefined + }); + + await mcpServer.connect(serverTransport); + + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + const baseUrl = await listenOnRandomPort(server); + + return { server, mcpServer, serverTransport, baseUrl }; +} + +describe('Zod v4', () => { + describe('Streamable HTTP Transport Session Management', () => { + // Function to set up the server with optional session management + describe('Stateless Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(false); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); + + it('should support multiple client connections', async () => { + // Create and connect a client + const client1 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + + // Verify that no session ID was set + expect(transport1.sessionId).toBeUndefined(); + + // List available tools + await client1.request({ + method: 'tools/list', + params: {} + }); + + const client2 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport2 = new StreamableHTTPClientTransport(baseUrl); + await client2.connect(transport2); + + // Verify that no session ID was set + expect(transport2.sessionId).toBeUndefined(); + + // List available tools + await client2.request({ + method: 'tools/list', + params: {} + }); + }); + it('should operate without session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that no session ID was set + expect(transport.sessionId).toBeUndefined(); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual( + expect.objectContaining({ + name: 'greet' + }) + ); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual( + expect.objectContaining({ + name: 'test-prompt' + }) + ); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateless Transport' + } + } + }); + + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]); + + // Clean up + await transport.close(); + }); + + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); + + await client.connect(transport); + + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + + // Clean up + await transport.close(); + }); + }); + + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); + + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request({ + method: 'tools/list', + params: {} + }); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual( + expect.objectContaining({ + name: 'greet' + }) + ); + + // List available resources + const resourcesResult = await client.request({ + method: 'resources/list', + params: {} + }); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request({ + method: 'prompts/list', + params: {} + }); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual( + expect.objectContaining({ + name: 'test-prompt' + }) + ); + + // Call the greeting tool + const greetingResult = await client.request({ + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateful Transport' + } + } + }); + + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]); + + // Clean up + await transport.close(); + }); + }); + }); +}); diff --git a/test/integration/test/taskLifecycle.test.ts b/test/integration/test/taskLifecycle.test.ts new file mode 100644 index 0000000..1a540df --- /dev/null +++ b/test/integration/test/taskLifecycle.test.ts @@ -0,0 +1,1625 @@ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { TaskRequestOptions } from '@modelcontextprotocol/server'; +import { + InMemoryTaskMessageQueue, + InMemoryTaskStore, + McpServer, + ProtocolError, + ProtocolErrorCode, + RELATED_TASK_META_KEY +} from '@modelcontextprotocol/server'; +import { listenOnRandomPort, waitForTaskStatus } from '@modelcontextprotocol/test-helpers'; +import * as z from 'zod/v4'; + +describe('Task Lifecycle Integration Tests', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let taskStore: InMemoryTaskStore; + + beforeEach(async () => { + // Create task store + taskStore = new InMemoryTaskStore(); + + // Create MCP server with task support + mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: { + tasks: { + requests: { + tools: { + call: {} + } + }, + list: {}, + cancel: {}, + taskStore, + taskMessageQueue: new InMemoryTaskMessageQueue() + } + } + } + ); + + // Register a long-running tool using registerToolTask + mcpServer.experimental.tasks.registerToolTask( + 'long-task', + { + title: 'Long Running Task', + description: 'A tool that takes time to complete', + inputSchema: z.object({ + duration: z.number().describe('Duration in milliseconds').default(1000), + shouldFail: z.boolean().describe('Whether the task should fail').default(false) + }) + }, + { + async createTask({ duration, shouldFail }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Simulate async work + (async () => { + await new Promise(resolve => setTimeout(resolve, duration)); + + try { + await (shouldFail + ? ctx.task.store.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: 'Task failed as requested' }], + isError: true + }) + : ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Completed after ${duration}ms` }] + })); + } catch { + // Task may have been cleaned up if test ended + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + // Register a tool that requires input via elicitation + mcpServer.experimental.tasks.registerToolTask( + 'input-task', + { + title: 'Input Required Task', + description: 'A tool that requires user input', + inputSchema: z.object({ + userName: z.string().describe('User name').optional() + }) + }, + { + async createTask({ userName }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Perform async work that requires elicitation + (async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + + // If userName not provided, request it via elicitation + if (userName) { + // Complete immediately if userName was provided + try { + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Hello, ${userName}!` }] + }); + } catch { + // Task may have been cleaned up if test ended + } + } else { + const elicitationResult = await ctx.mcpReq.send( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: 'What is your name?', + requestedSchema: { + type: 'object', + properties: { + userName: { type: 'string' } + }, + required: ['userName'] + } + } + }, + { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions + ); + + // Complete with the elicited name + const name = + elicitationResult.action === 'accept' && elicitationResult.content + ? elicitationResult.content.userName + : 'Unknown'; + try { + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }); + } catch { + // Task may have been cleaned up if test ended + } + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + // Create transport + serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + await mcpServer.connect(serverTransport); + + // Create HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start server + baseUrl = await listenOnRandomPort(server); + }); + + afterEach(async () => { + taskStore.cleanup(); + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); + + describe('Task Creation and Completion', () => { + it('should create a task and return CreateTaskResult', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 500, + shouldFail: false + }, + task: { + ttl: 60_000 + } + } + }); + + // Verify CreateTaskResult structure + expect(createResult).toHaveProperty('task'); + expect(createResult.task).toHaveProperty('taskId'); + expect(createResult.task.status).toBe('working'); + expect(createResult.task.ttl).toBe(60_000); + expect(createResult.task.createdAt).toBeDefined(); + expect(createResult.task.pollInterval).toBe(100); + + // Verify task is stored in taskStore + const taskId = createResult.task.taskId; + const storedTask = await taskStore.getTask(taskId); + expect(storedTask).toBeDefined(); + expect(storedTask?.taskId).toBe(taskId); + expect(storedTask?.status).toBe('working'); + + // Wait for completion + const completedTask = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); + + // Verify task completed + expect(completedTask.status).toBe('completed'); + + // Verify result is stored + const result = await taskStore.getTaskResult(taskId); + expect(result).toBeDefined(); + expect(result.content).toEqual([{ type: 'text', text: 'Completed after 500ms' }]); + + await transport.close(); + }); + + it('should handle task failure correctly', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task that will fail + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 300, + shouldFail: true + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Wait for failure + const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'failed'); + + // Verify task failed + expect(task.status).toBe('failed'); + + // Verify error result is stored + const result = await taskStore.getTaskResult(taskId); + expect(result.content).toEqual([{ type: 'text', text: 'Task failed as requested' }]); + expect(result.isError).toBe(true); + + await transport.close(); + }); + }); + + describe('Task Cancellation', () => { + it('should cancel a working task and return the cancelled task', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: {} } + } + ); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a long-running task + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 5000 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Verify task is working + let task = await taskStore.getTask(taskId); + expect(task?.status).toBe('working'); + + // Cancel the task via client.experimental.tasks.cancelTask - per spec, returns Result & Task + const cancelResult = await client.experimental.tasks.cancelTask(taskId); + + // Verify the cancel response includes the cancelled task (per MCP spec CancelTaskResult is Result & Task) + expect(cancelResult.taskId).toBe(taskId); + expect(cancelResult.status).toBe('cancelled'); + expect(cancelResult.createdAt).toBeDefined(); + expect(cancelResult.lastUpdatedAt).toBeDefined(); + expect(cancelResult.ttl).toBeDefined(); + + // Verify task is cancelled in store as well + task = await taskStore.getTask(taskId); + expect(task?.status).toBe('cancelled'); + + await transport.close(); + }); + + it('should reject cancellation of completed task with error code -32602', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: {} } + } + ); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a quick task + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 100 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Wait for completion + const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); + + // Verify task is completed + expect(task.status).toBe('completed'); + + // Try to cancel via tasks/cancel request (should fail with -32602) + await expect(client.experimental.tasks.cancelTask(taskId)).rejects.toSatisfy((error: ProtocolError) => { + expect(error).toBeInstanceOf(ProtocolError); + expect(error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(error.message).toContain('Cannot cancel task in terminal status'); + return true; + }); + + await transport.close(); + }); + }); + + describe('Multiple Queued Messages', () => { + it('should deliver multiple queued messages in order', async () => { + // Register a tool that sends multiple server requests during execution + mcpServer.experimental.tasks.registerToolTask( + 'multi-request-task', + { + title: 'Multi Request Task', + description: 'A tool that sends multiple server requests', + inputSchema: z.object({ + requestCount: z.number().describe('Number of requests to send').default(3) + }) + }, + { + async createTask({ requestCount }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Perform async work that sends multiple requests + (async () => { + await new Promise(resolve => setTimeout(resolve, 100)); + + const responses: string[] = []; + + // Send multiple elicitation requests + for (let i = 0; i < requestCount; i++) { + const elicitationResult = await ctx.mcpReq.send( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: `Request ${i + 1} of ${requestCount}`, + requestedSchema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'] + } + } + }, + { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions + ); + + if (elicitationResult.action === 'accept' && elicitationResult.content) { + responses.push(elicitationResult.content.response as string); + } + } + + // Complete with all responses + try { + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Received responses: ${responses.join(', ')}` }] + }); + } catch { + // Task may have been cleaned up if test ended + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + const receivedMessages: Array<{ method: string; message: string }> = []; + + // Set up elicitation handler on client to track message order + client.setRequestHandler('elicitation/create', async request => { + // Track the message + receivedMessages.push({ + method: request.method, + message: request.params.message + }); + + // Extract the request number from the message + const match = request.params.message.match(/Request (\d+) of (\d+)/); + const requestNum = match ? match[1] : 'unknown'; + + // Respond with the request number + return { + action: 'accept' as const, + content: { + response: `Response ${requestNum}` + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task that will send 3 requests + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'multi-request-task', + arguments: { + requestCount: 3 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Wait for messages to be queued + await new Promise(resolve => setTimeout(resolve, 200)); + + // Call tasks/result to receive all queued messages + // This should deliver all 3 elicitation requests in order + const result = await client.request({ + method: 'tasks/result', + params: { taskId } + }); + + // Verify all messages were delivered in order + expect(receivedMessages.length).toBe(3); + expect(receivedMessages[0]!.message).toBe('Request 1 of 3'); + expect(receivedMessages[1]!.message).toBe('Request 2 of 3'); + expect(receivedMessages[2]!.message).toBe('Request 3 of 3'); + + // Verify final result includes all responses + expect(result.content).toEqual([{ type: 'text', text: 'Received responses: Response 1, Response 2, Response 3' }]); + + // Verify task is completed + const task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task.status).toBe('completed'); + + await transport.close(); + }, 10_000); + }); + + describe('Input Required Flow', () => { + it('should handle elicitation during tool execution', async () => { + // Complete flow phases: + // 1. Client creates task + // 2. Server queues elicitation request and sets status to input_required + // 3. Client polls tasks/get, sees input_required status + // 4. Client calls tasks/result to dequeue elicitation request + // 5. Client responds to elicitation + // 6. Server receives response, completes task + // 7. Client receives final result + + const elicitClient = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Track elicitation request receipt + let elicitationReceived = false; + let elicitationRequestMeta: Record | undefined; + + // Set up elicitation handler on client + elicitClient.setRequestHandler('elicitation/create', async request => { + elicitationReceived = true; + elicitationRequestMeta = request.params._meta; + + return { + action: 'accept' as const, + content: { + userName: 'TestUser' + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await elicitClient.connect(transport); + + // Phase 1: Create task + const createResult = await elicitClient.request({ + method: 'tools/call', + params: { + name: 'input-task', + arguments: {}, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + expect(createResult.task.status).toBe('working'); + + // Phase 2: Wait for server to queue elicitation and update status + const task = await waitForTaskStatus( + id => + elicitClient.request({ + method: 'tasks/get', + params: { taskId: id } + }), + taskId, + 'input_required', + { + intervalMs: createResult.task.pollInterval ?? 100 + } + ); + + // Verify we saw input_required status (not completed or failed) + expect(task.status).toBe('input_required'); + + // Phase 3: Call tasks/result to dequeue messages and get final result + // This should: + // - Deliver the queued elicitation request via SSE + // - Client handler responds + // - Server receives response, completes task + // - Return final result + const result = await elicitClient.request({ + method: 'tasks/result', + params: { taskId } + }); + + // Verify elicitation was received and processed + expect(elicitationReceived).toBe(true); + + // Verify the elicitation request had related-task metadata + expect(elicitationRequestMeta).toBeDefined(); + expect(elicitationRequestMeta?.[RELATED_TASK_META_KEY]).toEqual({ taskId }); + + // Verify final result + expect(result.content).toEqual([{ type: 'text', text: 'Hello, TestUser!' }]); + + // Verify task is now completed + const finalTask = await elicitClient.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(finalTask.status).toBe('completed'); + + await transport.close(); + }, 15_000); + }); + + describe('Task Listing and Pagination', () => { + it('should list tasks', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create multiple tasks + const taskIds: string[] = []; + for (let i = 0; i < 3; i++) { + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 1000 + }, + task: { + ttl: 60_000 + } + } + }); + taskIds.push(createResult.task.taskId); + } + + // List tasks using taskStore + const listResult = await taskStore.listTasks(); + + expect(listResult.tasks.length).toBeGreaterThanOrEqual(3); + expect(listResult.tasks.some(t => taskIds.includes(t.taskId))).toBe(true); + + await transport.close(); + }); + + it('should handle pagination with large datasets', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create 15 tasks (more than page size of 10) + for (let i = 0; i < 15; i++) { + await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 5000 + }, + task: { + ttl: 60_000 + } + } + }); + } + + // Get first page using taskStore + const page1 = await taskStore.listTasks(); + + expect(page1.tasks.length).toBe(10); + expect(page1.nextCursor).toBeDefined(); + + // Get second page + const page2 = await taskStore.listTasks(page1.nextCursor); + + expect(page2.tasks.length).toBeGreaterThanOrEqual(5); + + await transport.close(); + }); + }); + + describe('Error Handling', () => { + it('should return error code -32602 for non-existent task in tasks/get', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: {} } + } + ); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Try to get non-existent task via tasks/get request + await expect(client.experimental.tasks.getTask('non-existent-task-id')).rejects.toSatisfy((error: ProtocolError) => { + expect(error).toBeInstanceOf(ProtocolError); + expect(error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(error.message).toContain('Task not found'); + return true; + }); + + await transport.close(); + }); + + it('should return error code -32602 for non-existent task in tasks/cancel', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: {} } + } + ); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Try to cancel non-existent task via tasks/cancel request + await expect(client.experimental.tasks.cancelTask('non-existent-task-id')).rejects.toSatisfy((error: ProtocolError) => { + expect(error).toBeInstanceOf(ProtocolError); + expect(error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(error.message).toContain('Task not found'); + return true; + }); + + await transport.close(); + }); + + it('should return error code -32602 for non-existent task in tasks/result', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Try to get result of non-existent task via tasks/result request + await expect( + client.request({ + method: 'tasks/result', + params: { taskId: 'non-existent-task-id' } + }) + ).rejects.toSatisfy((error: ProtocolError) => { + expect(error).toBeInstanceOf(ProtocolError); + expect(error.code).toBe(ProtocolErrorCode.InvalidParams); + expect(error.message).toContain('Task not found'); + return true; + }); + + await transport.close(); + }); + }); + + describe('TTL and Cleanup', () => { + it('should respect TTL in task creation', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task with specific TTL + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 100 + }, + task: { + ttl: 5000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Verify TTL is set correctly + expect(createResult.task.ttl).toBe(60_000); // The task store uses 60000 as default + + // Task should exist + const task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task).toBeDefined(); + expect(task.ttl).toBe(60_000); + + await transport.close(); + }); + }); + + describe('Task Cancellation with Queued Messages', () => { + it('should clear queue and deliver no messages when task is cancelled before tasks/result', async () => { + // Register a tool that queues messages but doesn't complete immediately + mcpServer.experimental.tasks.registerToolTask( + 'cancellable-task', + { + title: 'Cancellable Task', + description: 'A tool that queues messages and can be cancelled', + inputSchema: z.object({ + messageCount: z.number().describe('Number of messages to queue').default(2) + }) + }, + { + async createTask({ messageCount }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Perform async work that queues messages + (async () => { + try { + await new Promise(resolve => setTimeout(resolve, 100)); + + // Queue multiple elicitation requests + for (let i = 0; i < messageCount; i++) { + // Send request but don't await - let it queue + ctx.mcpReq + .send( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: `Message ${i + 1} of ${messageCount}`, + requestedSchema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'] + } + } + }, + { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions + ) + .catch(() => { + // Ignore errors from cancelled requests + }); + } + + // Don't complete - let the task be cancelled + // Wait indefinitely (or until cancelled) + await new Promise(() => {}); + } catch { + // Ignore errors - task was cancelled + } + })().catch(() => { + // Catch any unhandled errors from the async execution + }); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + let elicitationCallCount = 0; + + // Set up elicitation handler to track if any messages are delivered + client.setRequestHandler('elicitation/create', async () => { + elicitationCallCount++; + return { + action: 'accept' as const, + content: { + response: 'Should not be called' + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task that will queue messages + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'cancellable-task', + arguments: { + messageCount: 2 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Wait for messages to be queued + await new Promise(resolve => setTimeout(resolve, 200)); + + // Verify task is in input_required state and messages are queued + let task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task.status).toBe('input_required'); + + // Cancel the task before calling tasks/result using the proper tasks/cancel request + // This will trigger queue cleanup via _clearTaskQueue in the handler + await client.request({ + method: 'tasks/cancel', + params: { taskId } + }); + + // Verify task is cancelled + task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task.status).toBe('cancelled'); + + // Attempt to call tasks/result + // When a task is cancelled, the system needs to clear the message queue + // and reject any pending message delivery promises, meaning no further + // messages should be delivered for a cancelled task. + try { + await client.request({ + method: 'tasks/result', + params: { taskId } + }); + } catch { + // tasks/result might throw an error for cancelled tasks without a result + // This is acceptable behavior + } + + // Verify no elicitation messages were delivered, as the queue should be cleared immediately on cancellation + expect(elicitationCallCount).toBe(0); + + // Verify queue remains cleared on subsequent calls + try { + await client.request({ + method: 'tasks/result', + params: { taskId } + }); + } catch { + // Expected - task is cancelled + } + + // Still no messages should have been delivered + expect(elicitationCallCount).toBe(0); + + await transport.close(); + }, 10_000); + }); + + describe('Continuous Message Delivery', () => { + it('should deliver messages immediately while tasks/result is blocking', async () => { + // Register a tool that queues messages over time + mcpServer.experimental.tasks.registerToolTask( + 'streaming-task', + { + title: 'Streaming Task', + description: 'A tool that sends messages over time', + inputSchema: z.object({ + messageCount: z.number().describe('Number of messages to send').default(3), + delayBetweenMessages: z.number().describe('Delay between messages in ms').default(200) + }) + }, + { + async createTask({ messageCount, delayBetweenMessages }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Perform async work that sends messages over time + (async () => { + try { + // Wait a bit before starting to send messages + await new Promise(resolve => setTimeout(resolve, 100)); + + const responses: string[] = []; + + // Send messages with delays between them + for (let i = 0; i < messageCount; i++) { + const elicitationResult = await ctx.mcpReq.send( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: `Streaming message ${i + 1} of ${messageCount}`, + requestedSchema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'] + } + } + }, + { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions + ); + + if (elicitationResult.action === 'accept' && elicitationResult.content) { + responses.push(elicitationResult.content.response as string); + } + + // Wait before sending next message (if not the last one) + if (i < messageCount - 1) { + await new Promise(resolve => setTimeout(resolve, delayBetweenMessages)); + } + } + + // Complete with all responses + try { + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: `Received all responses: ${responses.join(', ')}` }] + }); + } catch { + // Task may have been cleaned up if test ended + } + } catch (error) { + // Handle errors + try { + await ctx.task.store.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } catch { + // Task may have been cleaned up if test ended + } + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + const receivedMessages: Array<{ message: string; timestamp: number }> = []; + let tasksResultStartTime = 0; + + // Set up elicitation handler to track when messages arrive + client.setRequestHandler('elicitation/create', async request => { + const timestamp = Date.now(); + receivedMessages.push({ + message: request.params.message, + timestamp + }); + + // Extract the message number + const match = request.params.message.match(/Streaming message (\d+) of (\d+)/); + const messageNum = match ? match[1] : 'unknown'; + + // Respond immediately + return { + action: 'accept' as const, + content: { + response: `Response ${messageNum}` + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task that will send messages over time + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'streaming-task', + arguments: { + messageCount: 3, + delayBetweenMessages: 300 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Verify task is in working status + let task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task.status).toBe('working'); + + // Call tasks/result immediately (before messages are queued) + // This should block and deliver messages as they arrive + tasksResultStartTime = Date.now(); + const resultPromise = client.request({ + method: 'tasks/result', + params: { taskId } + }); + + // Wait for the task to complete and get the result + const result = await resultPromise; + + // Verify all 3 messages were delivered + expect(receivedMessages.length).toBe(3); + expect(receivedMessages[0]!.message).toBe('Streaming message 1 of 3'); + expect(receivedMessages[1]!.message).toBe('Streaming message 2 of 3'); + expect(receivedMessages[2]!.message).toBe('Streaming message 3 of 3'); + + // Verify messages were delivered over time (not all at once) + // The delay between messages should be approximately 300ms + const timeBetweenFirstAndSecond = receivedMessages[1]!.timestamp - receivedMessages[0]!.timestamp; + const timeBetweenSecondAndThird = receivedMessages[2]!.timestamp - receivedMessages[1]!.timestamp; + + // Allow some tolerance for timing (messages should be at least 200ms apart) + expect(timeBetweenFirstAndSecond).toBeGreaterThan(200); + expect(timeBetweenSecondAndThird).toBeGreaterThan(200); + + // Verify messages were delivered while tasks/result was blocking + // (all messages should arrive after tasks/result was called) + for (const msg of receivedMessages) { + expect(msg.timestamp).toBeGreaterThanOrEqual(tasksResultStartTime); + } + + // Verify final result is correct + expect(result.content).toEqual([{ type: 'text', text: 'Received all responses: Response 1, Response 2, Response 3' }]); + + // Verify task is now completed + task = await client.request({ + method: 'tasks/get', + params: { taskId } + }); + expect(task.status).toBe('completed'); + + await transport.close(); + }, 15_000); // Increase timeout to 15 seconds to allow for message delays + }); + + describe('Terminal Task with Queued Messages', () => { + it('should deliver queued messages followed by final result for terminal task', async () => { + // Register a tool that completes quickly and queues messages before completion + mcpServer.experimental.tasks.registerToolTask( + 'quick-complete-task', + { + title: 'Quick Complete Task', + description: 'A tool that queues messages and completes quickly', + inputSchema: z.object({ + messageCount: z.number().describe('Number of messages to queue').default(2) + }) + }, + { + async createTask({ messageCount }, ctx) { + const task = await ctx.task.store.createTask({ + ttl: 60_000, + pollInterval: 100 + }); + + // Perform async work that queues messages and completes quickly + (async () => { + try { + // Queue messages - these will be queued before the task completes + // We await each one starting to ensure they're queued before completing + for (let i = 0; i < messageCount; i++) { + // Start the request but don't wait for response + // The request gets queued when sendRequest is called + ctx.mcpReq + .send( + { + method: 'elicitation/create', + params: { + mode: 'form', + message: `Quick message ${i + 1} of ${messageCount}`, + requestedSchema: { + type: 'object', + properties: { + response: { type: 'string' } + }, + required: ['response'] + } + } + }, + { relatedTask: { taskId: task.taskId } } as unknown as TaskRequestOptions + ) + .catch(() => {}); + // Small delay to ensure message is queued before next iteration + await new Promise(resolve => setTimeout(resolve, 10)); + } + + // Complete the task after all messages are queued + try { + await ctx.task.store.storeTaskResult(task.taskId, 'completed', { + content: [{ type: 'text', text: 'Task completed quickly' }] + }); + } catch { + // Task may have been cleaned up if test ended + } + } catch (error) { + // Handle errors + try { + await ctx.task.store.storeTaskResult(task.taskId, 'failed', { + content: [{ type: 'text', text: `Error: ${error}` }], + isError: true + }); + } catch { + // Task may have been cleaned up if test ended + } + } + })(); + + return { task }; + }, + async getTask(_args, ctx) { + const task = await ctx.task.store.getTask(ctx.task.id); + if (!task) { + throw new Error(`Task ${ctx.task.id} not found`); + } + return task; + }, + async getTaskResult(_args, ctx) { + const result = await ctx.task.store.getTaskResult(ctx.task.id); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + const receivedMessages: Array<{ type: string; message?: string; content?: unknown }> = []; + + // Set up elicitation handler to track message order + client.setRequestHandler('elicitation/create', async request => { + receivedMessages.push({ + type: 'elicitation', + message: request.params.message + }); + + // Extract the message number + const match = request.params.message.match(/Quick message (\d+) of (\d+)/); + const messageNum = match ? match[1] : 'unknown'; + + return { + action: 'accept' as const, + content: { + response: `Response ${messageNum}` + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task that will complete quickly with queued messages + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'quick-complete-task', + arguments: { + messageCount: 2 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Wait for task to complete and messages to be queued + const task = await waitForTaskStatus(id => taskStore.getTask(id), taskId, 'completed'); + + // Verify task is in terminal status (completed) + expect(task.status).toBe('completed'); + + // Call tasks/result - should deliver queued messages followed by final result + const result = await client.request({ + method: 'tasks/result', + params: { taskId } + }); + + // Verify all queued messages were delivered before the final result + expect(receivedMessages.length).toBe(2); + expect(receivedMessages[0]!.message).toBe('Quick message 1 of 2'); + expect(receivedMessages[1]!.message).toBe('Quick message 2 of 2'); + + // Verify final result is correct + expect(result.content).toEqual([{ type: 'text', text: 'Task completed quickly' }]); + + // Verify queue is cleaned up - calling tasks/result again should only return the result + receivedMessages.length = 0; // Clear the array + + const result2 = await client.request({ + method: 'tasks/result', + params: { taskId } + }); + + // No messages should be delivered on second call (queue was cleaned up) + expect(receivedMessages.length).toBe(0); + expect(result2.content).toEqual([{ type: 'text', text: 'Task completed quickly' }]); + + await transport.close(); + }, 10_000); + }); + + describe('Concurrent Operations', () => { + it('should handle multiple concurrent task creations', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create multiple tasks concurrently + const promises = Array.from({ length: 5 }, () => + client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 500 + }, + task: { + ttl: 60_000 + } + } + }) + ); + + const results = await Promise.all(promises); + + // Verify all tasks were created with unique IDs + const taskIds = results.map(r => r.task.taskId); + expect(new Set(taskIds).size).toBe(5); + + // Verify all tasks are in working status + for (const result of results) { + expect(result.task.status).toBe('working'); + } + + await transport.close(); + }); + + it('should handle concurrent operations on same task', async () => { + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Create a task + const createResult = await client.request({ + method: 'tools/call', + params: { + name: 'long-task', + arguments: { + duration: 2000 + }, + task: { + ttl: 60_000 + } + } + }); + + const taskId = createResult.task.taskId; + + // Perform multiple concurrent gets + const getPromises = Array.from({ length: 5 }, () => + client.request({ + method: 'tasks/get', + params: { taskId } + }) + ); + + const tasks = await Promise.all(getPromises); + + // All should return the same task + for (const task of tasks) { + expect(task.taskId).toBe(taskId); + expect(task.status).toBe('working'); + } + + await transport.close(); + }); + }); + + describe('callToolStream with failed task', () => { + it('should yield stored result (isError: true) when task fails, not a generic ProtocolError', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: {} } + } + ); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Use callToolStream with shouldFail: true so the tool stores a failed result + const stream = client.experimental.tasks.callToolStream( + { name: 'long-task', arguments: { duration: 100, shouldFail: true } }, + { task: { ttl: 60_000 } } + ); + + // Collect all stream messages + const messages: Array<{ type: string; task?: unknown; result?: unknown; error?: unknown }> = []; + for await (const message of stream) { + messages.push(message); + } + + // First message should be taskCreated + expect(messages[0]!.type).toBe('taskCreated'); + + // Last message must be 'result' (carrying the stored isError content), + // NOT 'error' (which would mean the generic hardcoded ProtocolError was returned) + const lastMessage = messages.at(-1)!; + expect(lastMessage.type).toBe('result'); + + // The stored result should contain isError: true and the real failure content + const result = lastMessage.result as { content: Array<{ type: string; text: string }>; isError: boolean }; + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: 'Task failed as requested' }]); + + await transport.close(); + }, 15_000); + }); + + describe('callToolStream with elicitation', () => { + it('should deliver elicitation via callToolStream and complete task', async () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {}, + tasks: {} + } + } + ); + + // Track elicitation request receipt + let elicitationReceived = false; + let elicitationMessage = ''; + + // Set up elicitation handler on client + client.setRequestHandler('elicitation/create', async request => { + elicitationReceived = true; + elicitationMessage = request.params.message; + + return { + action: 'accept' as const, + content: { + userName: 'StreamUser' + } + }; + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Use callToolStream instead of raw request() + const stream = client.experimental.tasks.callToolStream( + { name: 'input-task', arguments: {} }, + { + task: { ttl: 60_000 } + } + ); + + // Collect all stream messages + const messages: Array<{ type: string; task?: unknown; result?: unknown; error?: unknown }> = []; + for await (const message of stream) { + messages.push(message); + } + + // Verify stream yielded expected message types + expect(messages.length).toBeGreaterThanOrEqual(2); + + // First message should be taskCreated + expect(messages[0]!.type).toBe('taskCreated'); + expect(messages[0]!.task).toBeDefined(); + + // Should have a taskStatus message + const statusMessages = messages.filter(m => m.type === 'taskStatus'); + expect(statusMessages.length).toBeGreaterThanOrEqual(1); + + // Last message should be result + const lastMessage = messages.at(-1)!; + expect(lastMessage.type).toBe('result'); + expect(lastMessage.result).toBeDefined(); + + // Verify elicitation was received and processed + expect(elicitationReceived).toBe(true); + expect(elicitationMessage).toContain('What is your name?'); + + // Verify result content + const result = lastMessage.result as { content: Array<{ type: string; text: string }> }; + expect(result.content).toEqual([{ type: 'text', text: 'Hello, StreamUser!' }]); + + await transport.close(); + }, 15_000); + }); +}); diff --git a/test/integration/test/taskResumability.test.ts b/test/integration/test/taskResumability.test.ts new file mode 100644 index 0000000..f7b4174 --- /dev/null +++ b/test/integration/test/taskResumability.test.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto'; +import type { Server } from 'node:http'; +import { createServer } from 'node:http'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/server'; +import { McpServer } from '@modelcontextprotocol/server'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; +import * as z from 'zod/v4'; + +/** + * Simple in-memory EventStore for testing resumability. + */ +class InMemoryEventStore implements EventStore { + private events = new Map(); + + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + this.events.set(eventId, { streamId, message }); + return eventId; + } + + async replayEventsAfter( + lastEventId: string, + { send }: { send: (eventId: string, message: JSONRPCMessage) => Promise } + ): Promise { + if (!lastEventId || !this.events.has(lastEventId)) return ''; + const streamId = lastEventId.split('_')[0] ?? ''; + if (!streamId) return ''; + + let found = false; + const sorted = [...this.events.entries()].toSorted((a, b) => a[0].localeCompare(b[0])); + for (const [eventId, { streamId: sid, message }] of sorted) { + if (sid !== streamId) continue; + if (eventId === lastEventId) { + found = true; + continue; + } + if (found) await send(eventId, message); + } + return streamId; + } +} + +describe('Zod v4', () => { + describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: NodeStreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + // Add a simple notification tool that completes quickly + mcpServer.registerTool( + 'send-notification', + { + description: 'Sends a single notification', + inputSchema: z.object({ + message: z.string().describe('Message to send').default('Test notification') + }) + }, + async ({ message }, ctx) => { + // Send notification immediately + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { + level: 'info', + data: message + } + }); + + return { + content: [{ type: 'text', text: 'Notification sent' }] + }; + } + ); + + // Add a long-running tool that sends multiple notifications + mcpServer.registerTool( + 'run-notifications', + { + description: 'Sends multiple notifications over time', + inputSchema: z.object({ + count: z.number().describe('Number of notifications to send').default(10), + interval: z.number().describe('Interval between notifications in ms').default(50) + }) + }, + async ({ count, interval }, ctx) => { + // Send notifications at specified intervals + for (let i = 0; i < count; i++) { + await ctx.mcpReq.notify({ + method: 'notifications/message', + params: { + level: 'info', + data: `Notification ${i + 1} of ${count}` + } + }); + + // Wait for the specified interval before sending next notification + if (i < count - 1) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); + + // Create a transport with the event store + serverTransport = new NodeStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); + + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + baseUrl = await listenOnRandomPort(server); + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); + + it('should store session ID when client connects', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientTitle = 'test-client-long-running'; + const notifications = []; + let lastEventId: string | undefined; + + // Create first client + const client1 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for first client + client1.setNotificationHandler('notifications/message', notification => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + const sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = vi.fn((eventId: string) => { + lastEventId = eventId; + }); + expect(lastEventId).toBeUndefined(); + // Start the notification tool with event tracking using request + const toolPromise = client1.request( + { + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 3, + interval: 10 + } + } + }, + { + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate + } + ); + + // Fix for node 18 test failures, allow some time for notifications to arrive + const maxWaitTime = 2000; // 2 seconds max wait + const pollInterval = 10; // Check every 10ms + const startTime = Date.now(); + while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) { + // Wait for some notifications to arrive (not all) - shorter wait time + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + // Verify we received some notifications and lastEventId was updated + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.length).toBeLessThan(4); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + // Disconnect first client without waiting for completion + // When we close the connection, it will cause a ConnectionClosed error for + // any in-progress requests, which is expected behavior + await transport1.close(); + // Save the promise so we can catch it after closing + const catchPromise = toolPromise.catch(error => { + // This error is expected - the connection was intentionally closed + if (error?.code !== -32_000) { + // ConnectionClosed error code + console.error('Unexpected error type during transport close:', error); + } + }); + + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); + + // Wait for the rejection to be handled + await catchPromise; + + // Create second client with same client ID + const client2 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); + + // Track replayed notifications separately + const replayedNotifications: unknown[] = []; + client2.setNotificationHandler('notifications/message', notification => { + if (notification.method === 'notifications/message') { + replayedNotifications.push(notification.params); + } + }); + + // Connect second client with same session ID + const transport2 = new StreamableHTTPClientTransport(baseUrl, { + sessionId + }); + await client2.connect(transport2); + + // Resume GET SSE stream with Last-Event-ID to replay missed events + // Per spec, resumption uses GET with Last-Event-ID header + await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate }); + + // Wait for replayed events to arrive via SSE + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify the test infrastructure worked - we received notifications in first session + // and captured the lastEventId for potential replay + expect(notifications.length).toBeGreaterThan(0); + expect(lastEventId).toBeDefined(); + + // Clean up + await transport2.close(); + }); + }); +}); diff --git a/test/integration/test/title.test.ts b/test/integration/test/title.test.ts new file mode 100644 index 0000000..588d300 --- /dev/null +++ b/test/integration/test/title.test.ts @@ -0,0 +1,227 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport } from '@modelcontextprotocol/core'; +import { McpServer, ResourceTemplate, Server } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +describe('Zod v4', () => { + describe('Title field backwards compatibility', () => { + it('should work with tools that have title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register tool with title + server.registerTool( + 'test-tool', + { + title: 'Test Tool Display Name', + description: 'A test tool', + inputSchema: z.object({ + value: z.string() + }) + }, + async () => ({ content: [{ type: 'text', text: 'result' }] }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0]!.name).toBe('test-tool'); + expect(tools.tools[0]!.title).toBe('Test Tool Display Name'); + expect(tools.tools[0]!.description).toBe('A test tool'); + }); + + it('should work with tools without title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register tool without title + server.registerTool('test-tool', { description: 'A test tool', inputSchema: z.object({ value: z.string() }) }, async () => ({ + content: [{ type: 'text', text: 'result' }] + })); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0]!.name).toBe('test-tool'); + expect(tools.tools[0]!.title).toBeUndefined(); + expect(tools.tools[0]!.description).toBe('A test tool'); + }); + + it('should work with prompts that have title using update', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register prompt with title by updating after creation + const prompt = server.registerPrompt('test-prompt', { description: 'A test prompt' }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'test' } }] + })); + prompt.update({ title: 'Test Prompt Display Name' }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0]!.name).toBe('test-prompt'); + expect(prompts.prompts[0]!.title).toBe('Test Prompt Display Name'); + expect(prompts.prompts[0]!.description).toBe('A test prompt'); + }); + + it('should work with prompts using registerPrompt', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register prompt with title using registerPrompt + server.registerPrompt( + 'test-prompt', + { + title: 'Test Prompt Display Name', + description: 'A test prompt', + argsSchema: z.object({ input: z.string() }) + }, + async ({ input }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `test: ${input}` } + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0]!.name).toBe('test-prompt'); + expect(prompts.prompts[0]!.title).toBe('Test Prompt Display Name'); + expect(prompts.prompts[0]!.description).toBe('A test prompt'); + expect(prompts.prompts[0]!.arguments).toHaveLength(1); + }); + + it('should work with resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register resource with title using registerResource + server.registerResource( + 'test-resource', + 'https://example.com/test', + { + title: 'Test Resource Display Name', + description: 'A test resource', + mimeType: 'text/plain' + }, + async () => ({ + contents: [ + { + uri: 'https://example.com/test', + text: 'test content' + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0]!.name).toBe('test-resource'); + expect(resources.resources[0]!.title).toBe('Test Resource Display Name'); + expect(resources.resources[0]!.description).toBe('A test resource'); + expect(resources.resources[0]!.mimeType).toBe('text/plain'); + }); + + it('should work with dynamic resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register dynamic resource with title using registerResource + server.registerResource( + 'user-profile', + new ResourceTemplate('users://{userId}/profile', { list: undefined }), + { + title: 'User Profile', + description: 'User profile information' + }, + async (uri, { userId }, _extra) => ({ + contents: [ + { + uri: uri.href, + text: `Profile data for user ${userId}` + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0]!.name).toBe('user-profile'); + expect(resourceTemplates.resourceTemplates[0]!.title).toBe('User Profile'); + expect(resourceTemplates.resourceTemplates[0]!.description).toBe('User profile information'); + expect(resourceTemplates.resourceTemplates[0]!.uriTemplate).toBe('users://{userId}/profile'); + + // Test reading the resource + const readResult = await client.readResource({ uri: 'users://123/profile' }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Profile data for user 123'), + uri: 'users://123/profile' + } + ]) + ); + }); + + it('should support serverInfo with title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0', + title: 'Test Server Display Name' + }, + { capabilities: {} } + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('test-server'); + expect(serverInfo?.version).toBe('1.0.0'); + expect(serverInfo?.title).toBe('Test Server Display Name'); + }); + }); +}); diff --git a/test/integration/tsconfig.json b/test/integration/tsconfig.json new file mode 100644 index 0000000..4a2820d --- /dev/null +++ b/test/integration/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist", "test/server/bun.test.ts", "test/server/deno.test.ts"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"], + "@modelcontextprotocol/core/public": ["./node_modules/@modelcontextprotocol/core/src/exports/public/index.ts"], + "@modelcontextprotocol/core/validators/cfWorker": [ + "./node_modules/@modelcontextprotocol/core/src/validators/cfWorkerProvider.ts" + ], + "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/stdio": ["./node_modules/@modelcontextprotocol/client/src/stdio.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], + "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/stdio": ["./node_modules/@modelcontextprotocol/server/src/stdio.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], + "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], + "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"] + } + } +} diff --git a/test/integration/vitest.config.js b/test/integration/vitest.config.js new file mode 100644 index 0000000..5aad005 --- /dev/null +++ b/test/integration/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import baseConfig from '../../common/vitest-config/vitest.config.js'; + +export default mergeConfig( + baseConfig, + defineConfig({ + test: { + exclude: ['**/dist/**', '**/bun.test.ts', '**/deno.test.ts'] + } + }) +); diff --git a/typedoc.config.mjs b/typedoc.config.mjs new file mode 100644 index 0000000..f2a4e50 --- /dev/null +++ b/typedoc.config.mjs @@ -0,0 +1,56 @@ +import { OptionDefaults } from 'typedoc'; +import fg from 'fast-glob'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +// Find all package.json files under packages/ and build package list +const packageJsonPaths = await fg('packages/**/package.json', { + cwd: process.cwd(), + ignore: ['**/node_modules/**'] +}); +const packages = packageJsonPaths.map(p => { + const rootDir = join(process.cwd(), p.replace('/package.json', '')); + const manifest = JSON.parse(readFileSync(join(process.cwd(), p), 'utf8')); + return { rootDir, manifest }; +}); + +const publicPackages = packages.filter(p => p.manifest.private !== true); +const entryPoints = publicPackages.map(p => p.rootDir); + +console.log( + 'Typedoc selected public packages:', + publicPackages.map(p => p.manifest.name) +); + +/** @type {Partial} */ +export default { + name: 'MCP TypeScript SDK (V2)', + entryPointStrategy: 'packages', + entryPoints, + packageOptions: { + blockTags: [...OptionDefaults.blockTags, '@format'], + exclude: ['**/*.examples.ts'] + }, + highlightLanguages: [...OptionDefaults.highlightLanguages, 'powershell'], + projectDocuments: ['docs/documents.md', 'packages/middleware/README.md', 'examples/server/README.md', 'examples/client/README.md'], + hostedBaseUrl: 'https://ts.sdk.modelcontextprotocol.io/v2/', + navigationLinks: { + 'V1 Docs': '/' + }, + navigation: { + compactFolders: true, + includeFolders: false + }, + headings: { + readme: false + }, + customJs: 'docs/v2-banner.js', + treatWarningsAsErrors: true, + out: 'tmp/docs/', + externalSymbolLinkMappings: { + '@modelcontextprotocol/core': { + StandardSchemaV1: 'https://standardschema.dev/', + StandardJSONSchemaV1: 'https://standardschema.dev/' + } + } +}; diff --git a/vitest.workspace.js b/vitest.workspace.js new file mode 100644 index 0000000..b09f1f1 --- /dev/null +++ b/vitest.workspace.js @@ -0,0 +1,3 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace(['packages/**/vitest.config.js']);