From be2e26437c22d1f8ad46c27445760c49be16fa92 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 10:02:39 +0200 Subject: [PATCH 01/22] proof of concept: v2 monorepo, package split --- common/tsconfig/package.json | 21 + common/tsconfig/tsconfig.json | 21 + common/tsconfig/vitest.config.ts | 10 + common/vitest-config/package.json | 24 + common/vitest-config/tsconfig.json | 8 + packages/client/package.json | 109 + packages/client/src/client/auth-extensions.ts | 401 ++ packages/client/src/client/auth.ts | 1298 +++++++ packages/client/src/client/index.ts | 905 +++++ packages/client/src/client/middleware.ts | 320 ++ packages/client/src/client/sse.ts | 296 ++ packages/client/src/client/stdio.ts | 263 ++ packages/client/src/client/streamableHttp.ts | 674 ++++ packages/client/src/client/websocket.ts | 74 + packages/client/src/experimental/index.ts | 13 + .../client/src/experimental/tasks/client.ts | 264 ++ .../experimental/tasks/stores/in-memory.ts | 295 ++ packages/client/src/index.ts | 0 packages/client/test/.gitkeep | 0 packages/client/tsconfig.json | 11 + packages/client/vitest.config.ts | 3 + packages/examples/README.md | 352 ++ packages/examples/package.json | 46 + .../src/client/elicitationUrlExample.ts | 791 ++++ .../src/client/multipleClientsParallel.ts | 154 + .../src/client/parallelToolCallsClient.ts | 196 + .../src/client/simpleClientCredentials.ts | 82 + .../examples/src/client/simpleOAuthClient.ts | 458 +++ .../src/client/simpleOAuthClientProvider.ts | 66 + .../src/client/simpleStreamableHttp.ts | 924 +++++ .../src/client/simpleTaskInteractiveClient.ts | 204 + .../examples/src/client/ssePollingClient.ts | 106 + .../streamableHttpWithSseFallbackClient.ts | 191 + .../server/README-simpleTaskInteractive.md | 161 + .../src/server/demoInMemoryOAuthProvider.ts | 249 ++ .../src/server/elicitationFormExample.ts | 471 +++ .../src/server/elicitationUrlExample.ts | 771 ++++ .../src/server/jsonResponseStreamableHttp.ts | 177 + .../src/server/mcpServerOutputSchema.ts | 80 + .../examples/src/server/simpleSseServer.ts | 174 + .../server/simpleStatelessStreamableHttp.ts | 171 + .../src/server/simpleStreamableHttp.ts | 751 ++++ .../src/server/simpleTaskInteractive.ts | 745 ++++ .../sseAndStreamableHttpCompatibleServer.ts | 251 ++ .../examples/src/server/ssePollingExample.ts | 151 + .../standaloneSseWithGetStreamableHttp.ts | 127 + .../src/server/toolWithSampleServer.ts | 57 + .../examples/src/shared/inMemoryEventStore.ts | 78 + packages/examples/tsconfig.json | 13 + packages/server/package.json | 109 + packages/server/src/experimental/index.ts | 13 + .../server/src/experimental/tasks/helpers.ts | 88 + .../server/src/experimental/tasks/index.ts | 19 + .../src/experimental/tasks/interfaces.ts | 276 ++ .../src/experimental/tasks/mcp-server.ts | 142 + .../server/src/experimental/tasks/server.ts | 131 + .../experimental/tasks/stores/in-memory.ts | 295 ++ packages/server/src/index.ts | 44 + packages/server/src/server/auth/clients.ts | 22 + packages/server/src/server/auth/errors.ts | 212 + .../src/server/auth/handlers/authorize.ts | 165 + .../src/server/auth/handlers/metadata.ts | 19 + .../src/server/auth/handlers/register.ts | 119 + .../server/src/server/auth/handlers/revoke.ts | 79 + .../server/src/server/auth/handlers/token.ts | 155 + .../server/auth/middleware/allowedMethods.ts | 20 + .../src/server/auth/middleware/bearerAuth.ts | 102 + .../src/server/auth/middleware/clientAuth.ts | 64 + packages/server/src/server/auth/provider.ts | 83 + .../server/auth/providers/proxyProvider.ts | 238 ++ packages/server/src/server/auth/router.ts | 240 ++ packages/server/src/server/completable.ts | 67 + packages/server/src/server/express.ts | 74 + packages/server/src/server/mcp.ts | 1542 ++++++++ .../server/middleware/hostHeaderValidation.ts | 79 + packages/server/src/server/server.ts | 669 ++++ packages/server/src/server/sse.ts | 220 ++ packages/server/src/server/stdio.ts | 92 + packages/server/src/server/streamableHttp.ts | 969 +++++ .../server/src/validation/ajv-provider.ts | 97 + .../src/validation/cfworker-provider.ts | 77 + packages/server/src/validation/types.ts | 63 + packages/server/test/.gitkeep | 0 packages/server/tsconfig.json | 12 + packages/server/vitest.config.ts | 3 + packages/shared/package.json | 110 + packages/shared/src/index.ts | 15 + packages/shared/src/shared/auth-utils.ts | 55 + packages/shared/src/shared/auth.ts | 231 ++ packages/shared/src/shared/metadataUtils.ts | 26 + packages/shared/src/shared/protocol.ts | 1657 ++++++++ packages/shared/src/shared/responseMessage.ts | 70 + packages/shared/src/shared/stdio.ts | 39 + .../shared/src/shared/toolNameValidation.ts | 115 + packages/shared/src/shared/transport.ts | 128 + packages/shared/src/shared/uriTemplate.ts | 287 ++ packages/shared/src/types/spec.types.ts | 2587 +++++++++++++ packages/shared/src/types/types.ts | 2593 +++++++++++++ packages/shared/src/util/inMemory.ts | 63 + packages/shared/src/util/zod-compat.ts | 280 ++ .../shared/src/util/zod-json-schema-compat.ts | 68 + packages/shared/test/.gitkeep | 0 packages/shared/tsconfig.json | 8 + packages/shared/vitest.config.ts | 3 + pnpm-lock.yaml | 3420 +++++++++++++++++ pnpm-workspace.yaml | 11 + 106 files changed, 31372 insertions(+) create mode 100644 common/tsconfig/package.json create mode 100644 common/tsconfig/tsconfig.json create mode 100644 common/tsconfig/vitest.config.ts create mode 100644 common/vitest-config/package.json create mode 100644 common/vitest-config/tsconfig.json create mode 100644 packages/client/package.json create mode 100644 packages/client/src/client/auth-extensions.ts create mode 100644 packages/client/src/client/auth.ts create mode 100644 packages/client/src/client/index.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.ts create mode 100644 packages/client/src/client/websocket.ts create mode 100644 packages/client/src/experimental/index.ts create mode 100644 packages/client/src/experimental/tasks/client.ts create mode 100644 packages/client/src/experimental/tasks/stores/in-memory.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/test/.gitkeep create mode 100644 packages/client/tsconfig.json create mode 100644 packages/client/vitest.config.ts create mode 100644 packages/examples/README.md create mode 100644 packages/examples/package.json create mode 100644 packages/examples/src/client/elicitationUrlExample.ts create mode 100644 packages/examples/src/client/multipleClientsParallel.ts create mode 100644 packages/examples/src/client/parallelToolCallsClient.ts create mode 100644 packages/examples/src/client/simpleClientCredentials.ts create mode 100644 packages/examples/src/client/simpleOAuthClient.ts create mode 100644 packages/examples/src/client/simpleOAuthClientProvider.ts create mode 100644 packages/examples/src/client/simpleStreamableHttp.ts create mode 100644 packages/examples/src/client/simpleTaskInteractiveClient.ts create mode 100644 packages/examples/src/client/ssePollingClient.ts create mode 100644 packages/examples/src/client/streamableHttpWithSseFallbackClient.ts create mode 100644 packages/examples/src/server/README-simpleTaskInteractive.md create mode 100644 packages/examples/src/server/demoInMemoryOAuthProvider.ts create mode 100644 packages/examples/src/server/elicitationFormExample.ts create mode 100644 packages/examples/src/server/elicitationUrlExample.ts create mode 100644 packages/examples/src/server/jsonResponseStreamableHttp.ts create mode 100644 packages/examples/src/server/mcpServerOutputSchema.ts create mode 100644 packages/examples/src/server/simpleSseServer.ts create mode 100644 packages/examples/src/server/simpleStatelessStreamableHttp.ts create mode 100644 packages/examples/src/server/simpleStreamableHttp.ts create mode 100644 packages/examples/src/server/simpleTaskInteractive.ts create mode 100644 packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts create mode 100644 packages/examples/src/server/ssePollingExample.ts create mode 100644 packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts create mode 100644 packages/examples/src/server/toolWithSampleServer.ts create mode 100644 packages/examples/src/shared/inMemoryEventStore.ts create mode 100644 packages/examples/tsconfig.json create mode 100644 packages/server/package.json create mode 100644 packages/server/src/experimental/index.ts create mode 100644 packages/server/src/experimental/tasks/helpers.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/mcp-server.ts create mode 100644 packages/server/src/experimental/tasks/server.ts create mode 100644 packages/server/src/experimental/tasks/stores/in-memory.ts create mode 100644 packages/server/src/index.ts create mode 100644 packages/server/src/server/auth/clients.ts create mode 100644 packages/server/src/server/auth/errors.ts create mode 100644 packages/server/src/server/auth/handlers/authorize.ts create mode 100644 packages/server/src/server/auth/handlers/metadata.ts create mode 100644 packages/server/src/server/auth/handlers/register.ts create mode 100644 packages/server/src/server/auth/handlers/revoke.ts create mode 100644 packages/server/src/server/auth/handlers/token.ts create mode 100644 packages/server/src/server/auth/middleware/allowedMethods.ts create mode 100644 packages/server/src/server/auth/middleware/bearerAuth.ts create mode 100644 packages/server/src/server/auth/middleware/clientAuth.ts create mode 100644 packages/server/src/server/auth/provider.ts create mode 100644 packages/server/src/server/auth/providers/proxyProvider.ts create mode 100644 packages/server/src/server/auth/router.ts create mode 100644 packages/server/src/server/completable.ts create mode 100644 packages/server/src/server/express.ts create mode 100644 packages/server/src/server/mcp.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/sse.ts create mode 100644 packages/server/src/server/stdio.ts create mode 100644 packages/server/src/server/streamableHttp.ts create mode 100644 packages/server/src/validation/ajv-provider.ts create mode 100644 packages/server/src/validation/cfworker-provider.ts create mode 100644 packages/server/src/validation/types.ts create mode 100644 packages/server/test/.gitkeep create mode 100644 packages/server/tsconfig.json create mode 100644 packages/server/vitest.config.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/shared/auth-utils.ts create mode 100644 packages/shared/src/shared/auth.ts create mode 100644 packages/shared/src/shared/metadataUtils.ts create mode 100644 packages/shared/src/shared/protocol.ts create mode 100644 packages/shared/src/shared/responseMessage.ts create mode 100644 packages/shared/src/shared/stdio.ts create mode 100644 packages/shared/src/shared/toolNameValidation.ts create mode 100644 packages/shared/src/shared/transport.ts create mode 100644 packages/shared/src/shared/uriTemplate.ts create mode 100644 packages/shared/src/types/spec.types.ts create mode 100644 packages/shared/src/types/types.ts create mode 100644 packages/shared/src/util/inMemory.ts create mode 100644 packages/shared/src/util/zod-compat.ts create mode 100644 packages/shared/src/util/zod-json-schema-compat.ts create mode 100644 packages/shared/test/.gitkeep create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/common/tsconfig/package.json b/common/tsconfig/package.json new file mode 100644 index 000000000..bbb1f2ebd --- /dev/null +++ b/common/tsconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modelcontextprotocol/tsconfig", + "private": true, + "main": "tsconfig.json", + "type": "module", + "dependencies": { + "typescript": "catalog:" + }, + "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 000000000..a2ad603c4 --- /dev/null +++ b/common/tsconfig/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "paths": { + "pkce-challenge": ["./node_modules/pkce-challenge/dist/index.node"] + }, + "types": ["node", "vitest/globals"] + } +} \ No newline at end of file diff --git a/common/tsconfig/vitest.config.ts b/common/tsconfig/vitest.config.ts new file mode 100644 index 000000000..f283689f1 --- /dev/null +++ b/common/tsconfig/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + include: ['test/**/*.test.ts'] + } +}); diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json new file mode 100644 index 000000000..ef389a90c --- /dev/null +++ b/common/vitest-config/package.json @@ -0,0 +1,24 @@ +{ + "name": "@modelcontextprotocol/vitest-config", + "private": true, + "main": "tsconfig.json", + "type": "module", + "dependencies": { + "typescript": "catalog:" + }, + "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", + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^" + } +} diff --git a/common/vitest-config/tsconfig.json b/common/vitest-config/tsconfig.json new file mode 100644 index 000000000..32203633b --- /dev/null +++ b/common/vitest-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 000000000..cd24fb2de --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,109 @@ +{ + "name": "@modelcontextprotocol/sdk-client", + "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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/*" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "typecheck": "tsgo --noEmit", + "build": "npm run build:esm", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/shared": "workspace:^", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@cfworker/json-schema": "^4.1.1", + "@eslint/js": "^9.39.1", + "@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.0", + "@types/express-serve-static-core": "^5.1.0", + "@types/node": "^22.12.0", + "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.12", + "@typescript/native-preview": "^7.0.0-dev.20251103.1", + "eslint": "^9.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.23.1", + "prettier": "3.6.2", + "supertest": "^7.0.0", + "tsx": "^4.16.5", + "typescript": "^5.5.4", + "typescript-eslint": "^8.48.1", + "vitest": "^4.0.8", + "ws": "^8.18.0" + } +} diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts new file mode 100644 index 000000000..f3908d2c2 --- /dev/null +++ b/packages/client/src/client/auth-extensions.ts @@ -0,0 +1,401 @@ +/** + * OAuth provider extensions for specialized authentication flows. + * + * This module provides ready-to-use OAuthClientProvider implementations + * for common machine-to-machine authentication scenarios. + */ + +import type { JWK } from 'jose'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js'; +import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; + +/** + * Helper to produce a private_key_jwt client authentication function. + * + * Usage: + * const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? }); + * // 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 (typeof globalThis.crypto === 'undefined') { + throw new TypeError( + 'crypto is not available, please ensure you add 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) { + if (alg.startsWith('HS')) { + key = options.privateKey; + } else { + // Assume PKCS#8 DER in Uint8Array for asymmetric algorithms + key = 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 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; +} + +/** + * 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 + * 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' + }; + } + + 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 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; +} + +/** + * 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 (RFC 7523 Section 2.2). + * + * @example + * 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' + }; + this.addClientAuthentication = createPrivateKeyJwtAuth({ + issuer: options.clientId, + subject: options.clientId, + privateKey: options.privateKey, + alg: options.algorithm, + lifetimeSeconds: options.jwtLifetimeSeconds + }); + } + + 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 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; +} + +/** + * OAuth provider for client_credentials grant with a static private_key_jwt assertion. + * + * This provider mirrors {@link 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' + }; + + 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; + } +} diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts new file mode 100644 index 000000000..4c82b5114 --- /dev/null +++ b/packages/client/src/client/auth.ts @@ -0,0 +1,1298 @@ +import pkceChallenge from 'pkce-challenge'; +import { LATEST_PROTOCOL_VERSION } from '../types.js'; +import { + OAuthClientMetadata, + OAuthClientInformation, + OAuthClientInformationMixed, + OAuthTokens, + OAuthMetadata, + OAuthClientInformationFull, + OAuthProtectedResourceMetadata, + OAuthErrorResponseSchema, + AuthorizationServerMetadata, + OpenIdProviderDiscoveryMetadataSchema +} from '../shared/auth.js'; +import { + OAuthClientInformationFullSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokensSchema +} from '../shared/auth.js'; +import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; +import { + InvalidClientError, + InvalidClientMetadataError, + InvalidGrantError, + OAUTH_ERRORS, + OAuthError, + ServerError, + UnauthorizedClientError +} from '../server/auth/errors.js'; +import { FetchLike } from '../shared/transport.js'; + +/** + * Function type for adding client authentication to token requests. + */ +export type AddClientAuthentication = ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata +) => void | Promise; + +/** + * 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. + */ +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 a 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 + * `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'): 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 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; +} + +export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; + +export class UnauthorizedError extends Error { + constructor(message?: string) { + super(message ?? 'Unauthorized'); + } +} + +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; + + // If server doesn't specify supported methods, use RFC 6749 defaults + if (supportedMethods.length === 0) { + return hasClientSecret ? 'client_secret_post' : 'none'; + } + + // Prefer the method returned by the server during client registration if valid and supported + if ( + 'token_endpoint_auth_method' in clientInformation && + clientInformation.token_endpoint_auth_method && + isClientAuthMethod(clientInformation.token_endpoint_auth_method) && + supportedMethods.includes(clientInformation.token_endpoint_auth_method) + ) { + return clientInformation.token_endpoint_auth_method; + } + + // 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 + */ +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) + */ +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) + */ +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) + */ +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 instance of the appropriate OAuthError subclass will be returned. + * If parsing fails, it falls back to a generic 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 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)); + const { error, error_description, error_uri } = result; + const errorClass = OAUTH_ERRORS[error] || ServerError; + return new errorClass(error_description || '', error_uri); + } 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 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 InvalidClientError || error instanceof UnauthorizedClientError) { + await provider.invalidateCredentials?.('all'); + return await authInternal(provider, options); + } else if (error instanceof InvalidGrantError) { + await provider.invalidateCredentials?.('tokens'); + return await authInternal(provider, options); + } + + // Throw otherwise + throw error; + } +} + +async function authInternal( + provider: OAuthClientProvider, + { + serverUrl, + authorizationCode, + scope, + resourceMetadataUrl, + fetchFn + }: { + serverUrl: string | URL; + authorizationCode?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + } +): Promise { + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + let authorizationServerUrl: string | URL | undefined; + + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); + if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + authorizationServerUrl = resourceMetadata.authorization_servers[0]; + } + } catch { + // Ignore errors and fall back to /.well-known/oauth-authorization-server + } + + /** + * If we don't get a valid authorization server metadata from protected resource metadata, + * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. + */ + if (!authorizationServerUrl) { + authorizationServerUrl = new URL('/', serverUrl); + } + + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + + const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { + fetchFn + }); + + // 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 InvalidClientMetadataError( + `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, + 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, + 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 instanceof 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: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope, + resource + }); + + await provider.saveCodeVerifier(codeVerifier); + await provider.redirectToAuthorization(authorizationUrl); + return 'REDIRECT'; +} + +/** + * 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(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`); + const match = wwwAuthHeader.match(pattern); + + if (match) { + // Pattern matches: field_name="value" or field_name=value (unquoted) + return match[1] || match[2]; + } + + return null; +} + +/** + * Extract resource_metadata from response header. + * @deprecated Use `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) { + return undefined; + } + + try { + return new URL(match[1]); + } catch { + return undefined; + } +} + +/** + * Looks up 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?.body?.cancel(); + throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + } + + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`); + } + return OAuthProtectedResourceMetadataSchema.parse(await response.json()); +} + +/** + * Helper function to handle fetch with CORS retry logic + */ +async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { + try { + return await fetchFn(url, { headers }); + } catch (error) { + if (error instanceof TypeError) { + if (headers) { + // CORS errors come back as TypeError, retry without headers + return fetchWithCorsRetry(url, undefined, fetchFn); + } else { + // We're getting CORS errors on retry too, return undefined + return undefined; + } + } + throw error; + } +} + +/** + * 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 { + return !response || (response.status >= 400 && response.status < 500 && pathname !== '/'); +} + +/** + * 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 with 404 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 `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?.body?.cancel(); + return undefined; + } + + if (!response.ok) { + await response.body?.cancel(); + 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) { + // Root path: https://example.com/.well-known/oauth-authorization-server + urlsToTry.push({ + url: new URL('/.well-known/oauth-authorization-server', url.origin), + type: 'oauth' + }); + + // OIDC: https://example.com/.well-known/openid-configuration + urlsToTry.push({ + 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); + } + + // 1. OAuth metadata at the given URL + // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 + urlsToTry.push({ + 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 + urlsToTry.push({ + 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 + urlsToTry.push({ + url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), + type: 'oidc' + }); + + return urlsToTry; +} + +/** + * Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata + * and 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 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.body?.cancel(); + // Continue looking for any 4xx response code. + if (response.status >= 400 && response.status < 500) { + continue; // Try next URL + } + throw new Error( + `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}` + ); + } + + // Parse and validate based on type + if (type === 'oauth') { + return OAuthMetadataSchema.parse(await response.json()); + } else { + return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + } + } + + return undefined; +} + +/** + * 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?.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 fetchToken when the provider + * doesn't implement 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 exchangeAuthorization, refreshAuthorization, and fetchToken. + */ +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); + } + + return OAuthTokensSchema.parse(await response.json()); +} + +/** + * 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 provider.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 + * // Provider for client_credentials: + * class MyProvider implements OAuthClientProvider { + * prepareTokenRequest(scope) { + * const params = new URLSearchParams({ grant_type: 'client_credentials' }); + * if (scope) params.set('scope', scope); + * return params; + * } + * // ... other methods + * } + * + * const tokens = await fetchToken(provider, authServerUrl, { metadata }); + */ +export async function fetchToken( + provider: OAuthClientProvider, + authorizationServerUrl: string | URL, + { + metadata, + resource, + authorizationCode, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + resource?: URL; + /** Authorization code for the default authorization_code grant flow */ + authorizationCode?: string; + fetchFn?: FetchLike; + } = {} +): Promise { + const 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(scope); + } + + // 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 RFC 7591. + */ +export async function registerClient( + authorizationServerUrl: string | URL, + { + metadata, + clientMetadata, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientMetadata: OAuthClientMetadata; + 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) + }); + + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthClientInformationFullSchema.parse(await response.json()); +} diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/index.ts new file mode 100644 index 000000000..28c0e6253 --- /dev/null +++ b/packages/client/src/client/index.ts @@ -0,0 +1,905 @@ +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import type { Transport } from '../shared/transport.js'; + +import { + type CallToolRequest, + CallToolResultSchema, + type ClientCapabilities, + type ClientNotification, + type ClientRequest, + type ClientResult, + type CompatibilityCallToolResultSchema, + type CompleteRequest, + CompleteResultSchema, + EmptyResultSchema, + ErrorCode, + type GetPromptRequest, + GetPromptResultSchema, + type Implementation, + InitializeResultSchema, + LATEST_PROTOCOL_VERSION, + type ListPromptsRequest, + ListPromptsResultSchema, + type ListResourcesRequest, + ListResourcesResultSchema, + type ListResourceTemplatesRequest, + ListResourceTemplatesResultSchema, + type ListToolsRequest, + ListToolsResultSchema, + type LoggingLevel, + McpError, + type ReadResourceRequest, + ReadResourceResultSchema, + type ServerCapabilities, + SUPPORTED_PROTOCOL_VERSIONS, + type SubscribeRequest, + type Tool, + type UnsubscribeRequest, + ElicitResultSchema, + ElicitRequestSchema, + CreateTaskResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + ToolListChangedNotificationSchema, + PromptListChangedNotificationSchema, + ResourceListChangedNotificationSchema, + ListChangedOptions, + ListChangedOptionsBaseSchema, + type ListChangedHandlers, + type Request, + type Notification, + type Result +} from '../types.js'; +import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; +import { + AnyObjectSchema, + SchemaOutput, + getObjectShape, + isZ4Schema, + safeParse, + type ZodV3Internal, + type ZodV4Internal +} from '../server/zod-compat.js'; +import type { RequestHandlerExtra } from '../shared/protocol.js'; +import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.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 }; +} + +export type ClientOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this client. + */ + capabilities?: ClientCapabilities; + + /** + * JSON Schema validator for tool output validation. + * + * The validator is used to validate structured content returned by tools + * against their declared output schemas. + * + * @default AjvJsonSchemaValidator + * + * @example + * ```typescript + * // ajv + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new AjvJsonSchemaValidator() + * } + * ); + * + * // @cfworker/json-schema + * const client = new Client( + * { name: 'my-client', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + * } + * ); + * ``` + */ + jsonSchemaValidator?: jsonSchemaValidator; + + /** + * Configure handlers for list changed notifications (tools, prompts, resources). + * + * @example + * ```typescript + * 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 connect() is called. + * + * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: + * + * ```typescript + * // Custom schemas + * const CustomRequestSchema = RequestSchema.extend({...}) + * const CustomNotificationSchema = NotificationSchema.extend({...}) + * const CustomResultSchema = ResultSchema.extend({...}) + * + * // Type aliases + * type CustomRequest = z.infer + * type CustomNotification = z.infer + * type CustomResult = z.infer + * + * // Create typed client + * const client = new Client({ + * name: "CustomClient", + * version: "1.0.0" + * }) + * ``` + */ +export class Client< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> extends Protocol { + private _serverCapabilities?: ServerCapabilities; + private _serverVersion?: Implementation; + 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; + + /** + * Initializes this client with the given name and version information. + */ + constructor( + private _clientInfo: Implementation, + options?: ClientOptions + ) { + super(options); + this._capabilities = options?.capabilities ?? {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + + // Store list changed config for setup after connection (when we know server capabilities) + if (options?.listChanged) { + this._pendingListChangedConfig = options.listChanged; + } + } + + /** + * 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', ToolListChangedNotificationSchema, config.tools, async () => { + const result = await this.listTools(); + return result.tools; + }); + } + + if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { + this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => { + const result = await this.listPrompts(); + return result.prompts; + }); + } + + if (config.resources && this._serverCapabilities?.resources?.listChanged) { + this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, 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); + } + + /** + * Override request handler registration to enforce client-side validation for elicitation. + */ + public override setRequestHandler( + requestSchema: T, + handler: ( + request: SchemaOutput, + extra: RequestHandlerExtra + ) => ClientResult | ResultT | Promise + ): void { + const shape = getObjectShape(requestSchema); + const methodSchema = shape?.method; + if (!methodSchema) { + throw new Error('Schema is missing a method literal'); + } + + // Extract literal value using type-safe property access + let methodValue: unknown; + if (isZ4Schema(methodSchema)) { + const v4Schema = methodSchema as unknown as ZodV4Internal; + const v4Def = v4Schema._zod?.def; + methodValue = v4Def?.value ?? v4Schema.value; + } else { + const v3Schema = methodSchema as unknown as ZodV3Internal; + const legacyDef = v3Schema._def; + methodValue = legacyDef?.value ?? v3Schema.value; + } + + if (typeof methodValue !== 'string') { + throw new Error('Schema method literal must be a string'); + } + const method = methodValue; + if (method === 'elicitation/create') { + const wrappedHandler = async ( + request: SchemaOutput, + extra: RequestHandlerExtra + ): Promise => { + const validatedRequest = safeParse(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 McpError(ErrorCode.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 McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } + + if (params.mode === 'url' && !supportsUrlMode) { + throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); + } + + const result = await Promise.resolve(handler(request, extra)); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = safeParse(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against ElicitResultSchema + const validationResult = safeParse(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 McpError(ErrorCode.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) { + if (this._capabilities.elicitation?.form?.applyDefaults) { + try { + applyElicitationDefaults(requestedSchema, validatedResult.content); + } catch { + // gracefully ignore errors in default application + } + } + } + + return validatedResult; + }; + + // Install the wrapped handler + return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); + } + + if (method === 'sampling/createMessage') { + const wrappedHandler = async ( + request: SchemaOutput, + extra: RequestHandlerExtra + ): Promise => { + const validatedRequest = safeParse(CreateMessageRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); + } + + const { params } = validatedRequest.data; + + const result = await Promise.resolve(handler(request, extra)); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = safeParse(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against CreateMessageResultSchema + const validationResult = safeParse(CreateMessageResultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); + } + + return validationResult.data; + }; + + // Install the wrapped handler + return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); + } + + // Other handlers use default behavior + return super.setRequestHandler(requestSchema, handler); + } + + protected assertCapability(capability: keyof ServerCapabilities, method: string): void { + if (!this._serverCapabilities?.[capability]) { + throw new Error(`Server does not support ${capability} (required for ${method})`); + } + } + + 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. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } + try { + const result = await this.request( + { + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: this._capabilities, + clientInfo: this._clientInfo + } + }, + InitializeResultSchema, + options + ); + + if (result === undefined) { + throw new Error(`Server sent invalid initialize result: ${result}`); + } + + if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { + throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); + } + + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + // 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 may be populated with information about the server's instructions. + */ + getInstructions(): string | undefined { + return this._instructions; + } + + protected assertCapabilityForMethod(method: RequestT['method']): void { + switch (method as ClientRequest['method']) { + case 'logging/setLevel': + if (!this._serverCapabilities?.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'prompts/get': + case 'prompts/list': + if (!this._serverCapabilities?.prompts) { + throw new Error(`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 Error(`Server does not support resources (required for ${method})`); + } + + if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { + throw new Error(`Server does not support resource subscriptions (required for ${method})`); + } + + break; + + case 'tools/call': + case 'tools/list': + if (!this._serverCapabilities?.tools) { + throw new Error(`Server does not support tools (required for ${method})`); + } + break; + + case 'completion/complete': + if (!this._serverCapabilities?.completions) { + throw new Error(`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: NotificationT['method']): void { + switch (method as ClientNotification['method']) { + case 'notifications/roots/list_changed': + if (!this._capabilities.roots?.listChanged) { + throw new Error(`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 { + // Task handlers are registered in Protocol constructor before _capabilities is initialized + // Skip capability check for task methods during initialization + if (!this._capabilities) { + return; + } + + switch (method) { + case 'sampling/createMessage': + if (!this._capabilities.sampling) { + throw new Error(`Client does not support sampling capability (required for ${method})`); + } + break; + + case 'elicitation/create': + if (!this._capabilities.elicitation) { + throw new Error(`Client does not support elicitation capability (required for ${method})`); + } + break; + + case 'roots/list': + if (!this._capabilities.roots) { + throw new Error(`Client does not support roots capability (required for ${method})`); + } + break; + + case 'tasks/get': + case 'tasks/list': + case 'tasks/result': + case 'tasks/cancel': + if (!this._capabilities.tasks) { + throw new Error(`Client does not support tasks 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 { + // Task handlers are registered in Protocol constructor before _capabilities is initialized + // Skip capability check for task methods during initialization + if (!this._capabilities) { + return; + } + + assertClientRequestTaskCapability(this._capabilities.tasks?.requests, method, 'Client'); + } + + async ping(options?: RequestOptions) { + return this.request({ method: 'ping' }, EmptyResultSchema, options); + } + + async complete(params: CompleteRequest['params'], options?: RequestOptions) { + return this.request({ method: 'completion/complete', params }, CompleteResultSchema, options); + } + + async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + return this.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); + } + + async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { + return this.request({ method: 'prompts/get', params }, GetPromptResultSchema, options); + } + + async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { + return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); + } + + async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); + } + + async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); + } + + async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/read', params }, ReadResourceResultSchema, options); + } + + async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); + } + + async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { + return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); + } + + /** + * Calls a tool and waits for the result. Automatically validates structured output if the tool has an outputSchema. + * + * For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead. + */ + async callTool( + params: CallToolRequest['params'], + resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, + options?: RequestOptions + ) { + // Guard: required-task tools need experimental API + if (this.isToolTaskRequired(params.name)) { + throw new McpError( + ErrorCode.InvalidRequest, + `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` + ); + } + + const result = await this.request({ method: 'tools/call', params }, resultSchema, 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 McpError( + ErrorCode.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 McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.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 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 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); + } + + async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { + const result = await this.request({ 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, + notificationSchema: { shape: { method: { value: string } } }, + options: ListChangedOptions, + fetcher: () => Promise + ): void { + // Validate options using Zod schema (validates autoRefresh and debounceMs) + const parseResult = ListChangedOptionsBaseSchema.safeParse(options); + if (!parseResult.success) { + throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); + } + + // Validate callback + if (typeof options.onChanged !== 'function') { + throw new Error(`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 (e) { + const error = e instanceof Error ? e : new Error(String(e)); + onChanged(error, 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(notificationSchema as AnyObjectSchema, handler); + } + + async sendRootsListChanged() { + return this.notification({ method: 'notifications/roots/list_changed' }); + } +} diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts new file mode 100644 index 000000000..c8f7fdd3d --- /dev/null +++ b/packages/client/src/client/middleware.ts @@ -0,0 +1,320 @@ +import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { FetchLike } from '../shared/transport.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 (InvalidClientError, 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 = Array.from(requestHeaders.entries()) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + message += `\n Request Headers: {${reqHeaders}}`; + } + + if (includeResponseHeaders && responseHeaders) { + const resHeaders = Array.from(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 + * ```typescript + * // 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 => { + return middleware.reduce((handler, mw) => mw(handler), next); + }; +}; + +/** + * Helper function to create custom fetch middleware with cleaner syntax. + * Provides the next handler and request details as separate parameters for easier access. + * + * @example + * ```typescript + * // 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 000000000..f0e91ff25 --- /dev/null +++ b/packages/client/src/client/sse.ts @@ -0,0 +1,296 @@ +import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, 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 `SSEClientTransport`. + */ +export type SSEClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * When an `authProvider` is specified and the SSE connection is started: + * 1. The connection is attempted with any existing access token from the `authProvider`. + * 2. If the access token has expired, the `authProvider` is used to refresh the token. + * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. + * + * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. + * + * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. + * + * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. + */ + 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 `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 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?: 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; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; + this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + } + + private async _authThenStart(): Promise { + if (!this._authProvider) { + throw new UnauthorizedError('No auth provider'); + } + + let result: AuthResult; + try { + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return await this._startOrAuth(); + } + + private async _commonHeaders(): Promise { + const headers: HeadersInit & Record = {}; + if (this._authProvider) { + const tokens = await this._authProvider.tokens(); + if (tokens) { + headers['Authorization'] = `Bearer ${tokens.access_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 && 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) { + this._authThenStart().then(resolve, reject); + 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._authProvider) { + throw new UnauthorizedError('No auth provider'); + } + + const result = await auth(this._authProvider, { + 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 { + if (!this._endpoint) { + throw new Error('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) { + const text = await response.text().catch(() => null); + + if (response.status === 401 && this._authProvider) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + // Purposely _not_ awaited, so we don't call onerror twice + return this.send(message); + } + + throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); + } + + // Release connection - POST responses don't have content we need + await response.body?.cancel(); + } 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 000000000..e488dcd24 --- /dev/null +++ b/packages/client/src/client/stdio.ts @@ -0,0 +1,263 @@ +import { ChildProcess, IOType } from 'node:child_process'; +import spawn from 'cross-spawn'; +import process from 'node:process'; +import { Stream, PassThrough } from 'node:stream'; +import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; +import { Transport } from '../shared/transport.js'; +import { JSONRPCMessage } from '../types.js'; + +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 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' && isElectron(), + 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 `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, 2_000).unref())]); + + if (processToClose.exitCode === null) { + try { + processToClose.kill('SIGTERM'); + } catch { + // ignore + } + + await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).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 Error('Not connected'); + } + + const json = serializeMessage(message); + if (this._process.stdin.write(json)) { + resolve(); + } else { + this._process.stdin.once('drain', resolve); + } + }); + } +} + +function isElectron() { + return 'type' in process; +} diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts new file mode 100644 index 000000000..736587973 --- /dev/null +++ b/packages/client/src/client/streamableHttp.ts @@ -0,0 +1,674 @@ +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; +import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; + +// Default reconnection options for StreamableHTTP connections +const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { + initialReconnectionDelay: 1000, + maxReconnectionDelay: 30000, + reconnectionDelayGrowFactor: 1.5, + maxRetries: 2 +}; + +export class StreamableHTTPError extends Error { + constructor( + public readonly code: number | undefined, + message: string | undefined + ) { + super(`Streamable HTTP error: ${message}`); + } +} + +/** + * 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 response can be associate with the new resumed request. + */ + replayMessageId?: string | number; +} + +/** + * Configuration options for reconnection behavior of the 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; +} + +/** + * Configuration options for the `StreamableHTTPClientTransport`. + */ +export type StreamableHTTPClientTransportOptions = { + /** + * An OAuth client provider to use for authentication. + * + * When an `authProvider` is specified and the connection is started: + * 1. The connection is attempted with any existing access token from the `authProvider`. + * 2. If the access token has expired, the `authProvider` is used to refresh the token. + * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. + * + * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection. + * + * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. + * + * `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected. + */ + 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; + + /** + * 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; +}; + +/** + * 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?: OAuthClientProvider; + private _fetch?: FetchLike; + private _fetchWithInit: FetchLike; + private _sessionId?: string; + private _reconnectionOptions: StreamableHTTPReconnectionOptions; + private _protocolVersion?: string; + private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 + private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. + private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field + private _reconnectionTimeout?: ReturnType; + + 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; + this._authProvider = opts?.authProvider; + this._fetch = opts?.fetch; + this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + this._sessionId = opts?.sessionId; + this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; + } + + private async _authThenStart(): Promise { + if (!this._authProvider) { + throw new UnauthorizedError('No auth provider'); + } + + let result: AuthResult; + try { + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return await this._startOrAuthSse({ resumptionToken: undefined }); + } + + private async _commonHeaders(): Promise { + const headers: HeadersInit & Record = {}; + if (this._authProvider) { + const tokens = await this._authProvider.tokens(); + if (tokens) { + headers['Authorization'] = `Bearer ${tokens.access_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): 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(); + headers.set('Accept', 'text/event-stream'); + + // 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, { + method: 'GET', + headers, + signal: this._abortController?.signal + }); + + if (!response.ok) { + await response.body?.cancel(); + + if (response.status === 401 && this._authProvider) { + // Need to authenticate + return await this._authThenStart(); + } + + // 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 StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`); + } + + this._handleSseStream(response.body, options, true); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + } + + /** + * Calculates the next reconnection delay using 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); + + // Schedule the reconnection + this._reconnectionTimeout = setTimeout(() => { + // Use the last event ID to resume where we left off + this._startOrAuthSse(options).catch(error => { + this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); + // Schedule another attempt if this one failed, incrementing the attempt counter + this._scheduleReconnection(options, attemptCount + 1); + }); + }, delay); + } + + 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)); + if (isJSONRPCResultResponse(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._authProvider) { + throw new UnauthorizedError('No auth provider'); + } + + const result = await auth(this._authProvider, { + 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 { + if (this._reconnectionTimeout) { + clearTimeout(this._reconnectionTimeout); + this._reconnectionTimeout = undefined; + } + this._abortController?.abort(); + this.onclose?.(); + } + + async send( + message: JSONRPCMessage | JSONRPCMessage[], + options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } + ): Promise { + try { + const { resumptionToken, onresumptiontoken } = options || {}; + + if (resumptionToken) { + // If we have at last event ID, we need to reconnect the SSE stream + this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => + this.onerror?.(err) + ); + return; + } + + const headers = await this._commonHeaders(); + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + + 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) { + const text = await response.text().catch(() => null); + + if (response.status === 401 && this._authProvider) { + // Prevent infinite recursion when server returns 401 after successful auth + if (this._hasCompletedAuthFlow) { + throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication'); + } + + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); + this._resourceMetadataUrl = resourceMetadataUrl; + this._scope = scope; + + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetchWithInit + }); + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + // Mark that we completed auth flow + this._hasCompletedAuthFlow = true; + // Purposely _not_ awaited, so we don't call onerror twice + return this.send(message); + } + + if (response.status === 403 && this._authProvider) { + 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 StreamableHTTPError(403, 'Server returned 403 after trying upscoping'); + } + + if (scope) { + this._scope = scope; + } + + if (resourceMetadataUrl) { + this._resourceMetadataUrl = resourceMetadataUrl; + } + + // Mark that upscoping was tried. + this._lastUpscopingHeader = wwwAuthHeader ?? undefined; + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetch + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return this.send(message); + } + } + + throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); + } + + // Reset auth loop flag on successful response + this._hasCompletedAuthFlow = false; + this._lastUpscopingHeader = undefined; + + // If the response is 202 Accepted, there's no body to process + if (response.status === 202) { + await response.body?.cancel(); + // 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(err => this.onerror?.(err)); + } + return; + } + + // Get original message(s) for detecting request IDs + const messages = Array.isArray(message) ? message : [message]; + + const hasRequests = messages.filter(msg => 'method' in msg && 'id' in msg && msg.id !== undefined).length > 0; + + // 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.body?.cancel(); + throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`); + } + } else { + // No requests in message but got 200 OK - still need to release connection + await response.body?.cancel(); + } + } 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.body?.cancel(); + + // 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 StreamableHTTPError(response.status, `Failed to terminate session: ${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/client/websocket.ts b/packages/client/src/client/websocket.ts new file mode 100644 index 000000000..aed766caf --- /dev/null +++ b/packages/client/src/client/websocket.ts @@ -0,0 +1,74 @@ +import { Transport } from '../shared/transport.js'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; + +const SUBPROTOCOL = 'mcp'; + +/** + * Client transport for WebSocket: this will connect to a server over the WebSocket protocol. + */ +export class WebSocketClientTransport implements Transport { + private _socket?: WebSocket; + private _url: URL; + + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + constructor(url: URL) { + this._url = url; + } + + start(): Promise { + if (this._socket) { + throw new Error( + 'WebSocketClientTransport already started! If using Client class, note that connect() calls start() automatically.' + ); + } + + return new Promise((resolve, reject) => { + this._socket = new WebSocket(this._url, SUBPROTOCOL); + + this._socket.onerror = event => { + const error = 'error' in event ? (event.error as Error) : new Error(`WebSocket error: ${JSON.stringify(event)}`); + reject(error); + this.onerror?.(error); + }; + + this._socket.onopen = () => { + resolve(); + }; + + this._socket.onclose = () => { + this.onclose?.(); + }; + + this._socket.onmessage = (event: MessageEvent) => { + let message: JSONRPCMessage; + try { + message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); + } catch (error) { + this.onerror?.(error as Error); + return; + } + + this.onmessage?.(message); + }; + }); + } + + async close(): Promise { + this._socket?.close(); + } + + send(message: JSONRPCMessage): Promise { + return new Promise((resolve, reject) => { + if (!this._socket) { + reject(new Error('Not connected')); + return; + } + + this._socket?.send(JSON.stringify(message)); + resolve(); + }); + } +} diff --git a/packages/client/src/experimental/index.ts b/packages/client/src/experimental/index.ts new file mode 100644 index 000000000..55dd44ed0 --- /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/index.js'; diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts new file mode 100644 index 000000000..0f3f37118 --- /dev/null +++ b/packages/client/src/experimental/tasks/client.ts @@ -0,0 +1,264 @@ +/** + * Experimental client task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { Client } from '../../client/index.js'; +import type { RequestOptions } from '@modelcontextprotocol/shared'; +import type { ResponseMessage } from '@modelcontextprotocol/shared'; +import type { AnyObjectSchema, SchemaOutput } from '@modelcontextprotocol/shared'; +import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '@modelcontextprotocol/shared'; +import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/shared'; + +import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/shared'; + +/** + * Internal interface for accessing Client's private methods. + * @internal + */ +interface ClientInternal { + requestStream( + request: ClientRequest | RequestT, + resultSchema: T, + options?: RequestOptions + ): AsyncGenerator>, void, void>; + 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< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> { + constructor(private readonly _client: Client) {} + + /** + * 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 + * ```typescript + * 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 resultSchema - Zod schema for validating the result (defaults to CallToolResultSchema) + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + async *callToolStream( + params: CallToolRequest['params'], + resultSchema: T = CallToolResultSchema as T, + 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 = clientInternal.requestStream({ method: 'tools/call', params }, resultSchema, 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 + if (message.type === 'result' && validator) { + const result = message.result; + + // If tool has outputSchema, it MUST return structuredContent (unless it's an error) + if (!result.structuredContent && !result.isError) { + yield { + type: 'error', + error: new McpError( + ErrorCode.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 McpError( + ErrorCode.InvalidParams, + `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` + ) + }; + return; + } + } catch (error) { + if (error instanceof McpError) { + yield { type: 'error', error }; + return; + } + yield { + type: 'error', + error: new McpError( + ErrorCode.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 { + // Delegate to the client's underlying Protocol method + type ClientWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise }; + return (this._client as unknown as ClientWithGetTask).getTask({ taskId }, options); + } + + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options + * @returns The task result + * + * @experimental + */ + async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { + // Delegate to the client's underlying Protocol method + return ( + this._client as unknown as { + getTaskResult: ( + params: { taskId: string }, + resultSchema?: U, + options?: RequestOptions + ) => Promise>; + } + ).getTaskResult({ taskId }, resultSchema, 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 { + // Delegate to the client's underlying Protocol method + return ( + this._client as unknown as { + listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise; + } + ).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 { + // Delegate to the client's underlying Protocol method + return ( + this._client as unknown as { + cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise; + } + ).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. + * + * @param request - The request to send + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + requestStream( + request: ClientRequest | RequestT, + resultSchema: T, + options?: RequestOptions + ): AsyncGenerator>, void, void> { + // Delegate to the client's underlying Protocol method + type ClientWithRequestStream = { + requestStream( + request: ClientRequest | RequestT, + resultSchema: U, + options?: RequestOptions + ): AsyncGenerator>, void, void>; + }; + return (this._client as unknown as ClientWithRequestStream).requestStream(request, resultSchema, options); + } +} diff --git a/packages/client/src/experimental/tasks/stores/in-memory.ts b/packages/client/src/experimental/tasks/stores/in-memory.ts new file mode 100644 index 000000000..aff3ad910 --- /dev/null +++ b/packages/client/src/experimental/tasks/stores/in-memory.ts @@ -0,0 +1,295 @@ +/** + * In-memory implementations of TaskStore and TaskMessageQueue. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { Task, RequestId, Result, Request } from '../../../types.js'; +import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; +import { randomBytes } from 'node:crypto'; + +interface StoredTask { + task: Task; + request: Request; + requestId: RequestId; + result?: Result; +} + +/** + * A simple in-memory implementation of TaskStore for demonstration purposes. + * + * This implementation stores all tasks in memory and provides automatic cleanup + * based on the ttl duration specified in the task creation parameters. + * + * Note: This is not suitable for production use as all data is lost on restart. + * For production, consider implementing TaskStore with a database or distributed cache. + * + * @experimental + */ +export class InMemoryTaskStore implements TaskStore { + private tasks = new Map(); + private cleanupTimers = new Map>(); + + /** + * Generates a unique task ID. + * Uses 16 bytes of random data encoded as hex (32 characters). + */ + private generateTaskId(): string { + return randomBytes(16).toString('hex'); + } + + 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 + }); + + // 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; + } + + async getTask(taskId: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + return stored ? { ...stored.task } : null; + } + + async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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); + } + } + + async getTaskResult(taskId: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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; + } + + async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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); + } + } + + async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { + const PAGE_SIZE = 10; + const allTaskIds = Array.from(this.tasks.keys()); + + let startIndex = 0; + if (cursor) { + const cursorIndex = allTaskIds.indexOf(cursor); + if (cursorIndex >= 0) { + startIndex = cursorIndex + 1; + } else { + // Invalid cursor - throw error + throw new Error(`Invalid cursor: ${cursor}`); + } + } + + const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE); + const tasks = pageTaskIds.map(taskId => { + const stored = this.tasks.get(taskId)!; + return { ...stored.task }; + }); + + const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 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 Array.from(this.tasks.values()).map(stored => ({ ...stored.task })); + } +} + +/** + * A simple in-memory implementation of TaskMessageQueue for demonstration purposes. + * + * This implementation stores messages in memory, organized by task ID and optional session ID. + * Messages are stored in FIFO queues per task. + * + * Note: This is not suitable for production use in distributed systems. + * For production, consider implementing TaskMessageQueue with Redis or other distributed queues. + * + * @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/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/client/test/.gitkeep b/packages/client/test/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 000000000..12c974280 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"] + } + } +} diff --git a/packages/client/vitest.config.ts b/packages/client/vitest.config.ts new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/client/vitest.config.ts @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/examples/README.md b/packages/examples/README.md new file mode 100644 index 000000000..dd67bc8f8 --- /dev/null +++ b/packages/examples/README.md @@ -0,0 +1,352 @@ +# MCP TypeScript SDK Examples + +This directory contains example implementations of MCP clients and servers using the TypeScript SDK. For a high-level index of scenarios and where they live, see the **Examples** table in the root `README.md`. + +## Table of Contents + +- [Client Implementations](#client-implementations) + - [Streamable HTTP Client](#streamable-http-client) + - [Backwards Compatible Client](#backwards-compatible-client) + - [URL Elicitation Example Client](#url-elicitation-example-client) +- [Server Implementations](#server-implementations) + - [Single Node Deployment](#single-node-deployment) + - [Streamable HTTP Transport](#streamable-http-transport) + - [Deprecated SSE Transport](#deprecated-sse-transport) + - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) + - [Form Elicitation Example](#form-elicitation-example) + - [URL Elicitation Example](#url-elicitation-example) + - [Multi-Node Deployment](#multi-node-deployment) +- [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) + +## Client Implementations + +### Streamable HTTP Client + +A full-featured interactive client that connects to a Streamable HTTP server, demonstrating how to: + +- Establish and manage a connection to an MCP server +- List and call tools with arguments +- Handle notifications through the SSE stream +- List and get prompts with arguments +- List available resources +- Handle session termination and reconnection +- Support for resumability with Last-Event-ID tracking + +```bash +npx tsx src/examples/client/simpleStreamableHttp.ts +``` + +Example client with OAuth: + +```bash +npx tsx src/examples/client/simpleOAuthClient.ts +``` + +Client credentials (machine-to-machine) example: + +```bash +npx tsx src/examples/client/simpleClientCredentials.ts +``` + +### Backwards Compatible Client + +A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: + +- The client first POSTs an initialize request to the server URL: + - If successful, it uses the Streamable HTTP transport + - If it fails with a 4xx status, it attempts a GET request to establish an SSE stream + +```bash +npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts +``` + +### URL Elicitation Example Client + +A client that demonstrates how to use URL elicitation to securely collect _sensitive_ user input or perform secure third-party flows. + +```bash +# First, run the server: +npx tsx src/examples/server/elicitationUrlExample.ts + +# Then, run the client: +npx tsx src/examples/client/elicitationUrlExample.ts + +``` + +## Server Implementations + +### Single Node Deployment + +These examples demonstrate how to set up an MCP server on a single node with different transport options. + +#### Streamable HTTP Transport + +##### Simple Streamable HTTP Server + +A server that implements the Streamable HTTP transport (protocol version 2025-03-26). + +- Basic server setup with Express and the Streamable HTTP transport +- Session management with an in-memory event store for resumability +- Tool implementation with the `greet` and `multi-greet` tools +- Prompt implementation with the `greeting-template` prompt +- Static resource exposure +- Support for notifications via SSE stream established by GET requests +- Session termination via DELETE requests + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts + +# To add a demo of authentication to this example, use: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict +``` + +##### JSON Response Mode Server + +A server that uses Streamable HTTP transport with JSON response mode enabled (no SSE). + +- Streamable HTTP with JSON response mode, which returns responses directly in the response body +- Limited support for notifications (since SSE is disabled) +- Proper response handling according to the MCP specification for servers that don't support SSE +- Returning appropriate HTTP status codes for unsupported methods + +```bash +npx tsx src/examples/server/jsonResponseStreamableHttp.ts +``` + +##### Streamable HTTP with server notifications + +A server that demonstrates server notifications using Streamable HTTP. + +- Resource list change notifications with dynamically added resources +- Automatic resource creation on a timed interval + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +##### Form Elicitation Example + +A server that demonstrates using form elicitation to collect _non-sensitive_ user input. + +```bash +npx tsx src/examples/server/elicitationFormExample.ts +``` + +##### URL Elicitation Example + +A comprehensive example demonstrating URL mode elicitation in a server protected by MCP authorization. This example shows: + +- SSE-driven URL elicitation of an API Key on session initialization: obtain sensitive user input at session init +- Tools that require direct user interaction via URL elicitation (for payment confirmation and for third-party OAuth tokens) +- Completion notifications for URL elicitation + +To run this example: + +```bash +# Start the server +npx tsx src/examples/server/elicitationUrlExample.ts + +# In a separate terminal, start the client +npx tsx src/examples/client/elicitationUrlExample.ts +``` + +#### Deprecated SSE Transport + +A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example is only used for testing backwards compatibility for clients. + +- Two separate endpoints: `/mcp` for the SSE stream (GET) and `/messages` for client messages (POST) +- Tool implementation with a `start-notification-stream` tool that demonstrates sending periodic notifications + +```bash +npx tsx src/examples/server/simpleSseServer.ts +``` + +#### Streamable Http Backwards Compatible Server with SSE + +A server that supports both Streamable HTTP and SSE transports, adhering to the [MCP specification for backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility). + +- Single MCP server instance with multiple transport options +- Support for Streamable HTTP requests at `/mcp` endpoint (GET/POST/DELETE) +- Support for deprecated SSE transport with `/sse` (GET) and `/messages` (POST) +- Session type tracking to avoid mixing transport types +- Notifications and tool execution across both transport types + +```bash +npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts +``` + +### Multi-Node Deployment + +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 to MCP servers. Useful for simple API wrapper servers. +- **Persistent storage mode** - No local state needed, but session data is stored in a database. Example: an MCP server for online ordering where the shopping cart is stored in a database. +- **Local state with message routing** - Local state is needed, and all requests for a session must be routed to the correct node. This can be done with a message queue and pub/sub system. + +#### Stateless Mode + +The Streamable HTTP transport can be configured to operate without tracking sessions. This is perfect for simple API proxies or when each request is completely independent. + +##### Implementation + +To enable stateless mode, configure the `StreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +This disables session management entirely, and the server won't generate or expect session IDs. + +- No session ID headers are sent or expected +- Any server node can process any request +- No state is preserved between requests +- Perfect for RESTful or stateless API scenarios +- Simplest deployment model with minimal infrastructure requirements + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +#### Persistent Storage Mode + +For cases where you need session continuity but don't need to maintain in-memory state on specific nodes, you can use a database to persist session data while still allowing any node to handle requests. + +##### Implementation + +Configure the transport with session management, but retrieve and store all state in an external persistent storage: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +All session state is stored in the database, and any node can serve any client by retrieving the state when needed. + +- Maintains sessions with unique IDs +- Stores all session data in an external database +- Provides resumability through the database-backed EventStore +- Any node can handle any request for the same session +- No node-specific memory state means no need for message routing +- Good for applications where state can be fully externalized +- Somewhat higher latency due to database access for each request + +``` +┌─────────────────────────────────────────────┐ +│ 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 (such as Computer Use or complex session state), the Streamable HTTP transport can be combined with a pub/sub system to route messages to the correct node handling each session. + +1. **Bidirectional Message Queue Integration**: + - All nodes both publish to and subscribe from the message queue + - Each node registers the sessions it's actively handling + - Messages are routed based on session ownership + +2. **Request Handling Flow**: + - When a client connects to Node A with an existing `mcp-session-id` + - If Node A doesn't own this session, it: + - Establishes and maintains the SSE connection with the client + - Publishes the request to the message queue with the session ID + - Node B (which owns the session) receives the request from the queue + - Node B processes the request with its local session state + - Node B publishes responses/notifications back to the queue + - Node A subscribes to the response channel and forwards to the client + +3. **Channel Identification**: + - Each message channel combines both `mcp-session-id` and `stream-id` + - This ensures responses are correctly routed back to the originating connection + +``` +┌─────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────┘ +``` + +- Maintains session affinity for stateful operations without client redirection +- Enables horizontal scaling while preserving complex in-memory state +- Provides fault tolerance through the message queue as intermediary + +## Backwards Compatibility + +### Testing Streamable HTTP Backwards Compatibility with SSE + +To test the backwards compatibility features: + +1. Start one of the server implementations: + + ```bash + # Legacy SSE server (protocol version 2024-11-05) + npx tsx src/examples/server/simpleSseServer.ts + + # Streamable HTTP server (protocol version 2025-03-26) + npx tsx src/examples/server/simpleStreamableHttp.ts + + # Backwards compatible server (supports both protocols) + npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts + ``` + +2. Then run the backwards compatible client: + ```bash + npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts + ``` + +This demonstrates how the MCP ecosystem ensures interoperability between clients and servers regardless of which protocol version they were built for. diff --git a/packages/examples/package.json b/packages/examples/package.json new file mode 100644 index 000000000..2733b2110 --- /dev/null +++ b/packages/examples/package.json @@ -0,0 +1,46 @@ +{ + "name": "@modelcontextprotocol/sdk-examples", + "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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "npm run build:esm", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/shared": "workspace:^", + "@modelcontextprotocol/sdk-server": "workspace:^", + "@modelcontextprotocol/sdk-client": "workspace:^" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^" + } +} diff --git a/packages/examples/src/client/elicitationUrlExample.ts b/packages/examples/src/client/elicitationUrlExample.ts new file mode 100644 index 000000000..b57927e3f --- /dev/null +++ b/packages/examples/src/client/elicitationUrlExample.ts @@ -0,0 +1,791 @@ +// Run with: npx tsx src/examples/client/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 { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + ElicitRequestSchema, + ElicitRequest, + ElicitResult, + ResourceLink, + ElicitRequestURLParams, + McpError, + ErrorCode, + UrlElicitationRequiredError, + ElicitationCompleteNotificationSchema +} from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; +import { OAuthClientMetadata } from '../../shared/auth.js'; +import { exec } from 'node:child_process'; +import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; +import { UnauthorizedError } from '../../client/auth.js'; +import { createServer } from 'node:http'; + +// 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`; +let oauthProvider: InMemoryOAuthClientProvider | undefined = undefined; + +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' +}; +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 = 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); + 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) { + resolve(); + } else { + elicitationsCompleteSignal = 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; + } + } +} + +async function openBrowser(url: string): Promise { + const command = `open "${url}"`; + + exec(command, error => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + 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 McpError(ErrorCode.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⚠️ \x1b[33mSECURITY WARNING\x1b[0m ⚠️'); + console.log('\x1b[33mThe server is requesting you to open an external URL.\x1b[0m'); + console.log('\x1b[33mOnly proceed if you trust this server and understand why it needs this.\x1b[0m\n'); + console.log(`🌐 Target domain: \x1b[36m${domain}\x1b[0m`); + console.log(`🔗 Full URL: \x1b[36m${url}\x1b[0m`); + console.log(`\nℹ️ Server's reason:\n\n\x1b[36m${message}\x1b[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(`\x1b[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\x1b[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?.substring(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(), 15000); + } 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(ElicitRequestSchema, elicitationRequestHandler); + + // Set up notification handler for elicitation completion + client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { + const { elicitationId } = notification.params; + const pending = pendingURLElicitations.get(elicitationId); + if (pending) { + clearTimeout(pending.timeout); + pendingURLElicitations.delete(elicitationId); + console.log(`\x1b[32m✅ Elicitation ${elicitationId} completed!\x1b[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('\x1b[31mClient error:', error, '\x1b[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('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; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } 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, ListToolsResultSchema); + + 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 { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + const resourceLinks: ResourceLink[] = []; + + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else if (item.type === '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}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); + } else { + 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!'); + 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 +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/multipleClientsParallel.ts b/packages/examples/src/client/multipleClientsParallel.ts new file mode 100644 index 000000000..492235cdd --- /dev/null +++ b/packages/examples/src/client/multipleClientsParallel.ts @@ -0,0 +1,154 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '../../types.js'; + +/** + * 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(LoggingMessageNotificationSchema, 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 toolRequest: CallToolRequest = { + method: 'tools/call', + params: { + name: config.toolName, + arguments: { + ...config.toolArguments, + // Add client ID to arguments for identification in notifications + caller: config.id + } + } + }; + + const result = await client.request(toolRequest, CallToolResultSchema); + 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 ==='); + results.forEach(({ id, result }) => { + console.log(`\n[${id}] Tool result:`); + if (Array.isArray(result.content)) { + result.content.forEach((item: { type: string; text?: string }) => { + 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); + process.exit(1); + } +} + +// Start the example +main().catch((error: unknown) => { + console.error('Error running MCP multiple clients example:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/parallelToolCallsClient.ts b/packages/examples/src/client/parallelToolCallsClient.ts new file mode 100644 index 000000000..2ad249de7 --- /dev/null +++ b/packages/examples/src/client/parallelToolCallsClient.ts @@ -0,0 +1,196 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolResultSchema, + LoggingMessageNotificationSchema, + CallToolResult +} from '../../types.js'; + +/** + * 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(LoggingMessageNotificationSchema, 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} ===`); + result.content.forEach((item: { type: string; text?: string }) => { + 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, 10000)); + + // 4. Disconnect + console.log('\n=== Disconnecting ==='); + await transport.close(); + console.log('Disconnected from MCP server'); + } catch (error) { + console.error('Error running client:', error); + 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, ListToolsResultSchema); + + 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', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 2, // 0.5 second between notifications + count: 10, // Send 10 notifications + caller: 'fast-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'slow-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 5, // 2 seconds between notifications + count: 5, // Send 5 notifications + caller: 'slow-notifier' // Identify this tool call + } + } + } + }, + { + caller: 'burst-notifier', + request: { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + 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, request }) => { + console.log(`Starting tool call for ${caller}...`); + return client + .request(request, CallToolResultSchema) + .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 = {}; + results.forEach(({ caller, result }) => { + resultsByTool[caller] = result; + }); + + return resultsByTool; + } catch (error) { + console.error(`Error starting parallel notification tools:`, error); + throw error; + } +} + +// Start the client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/simpleClientCredentials.ts b/packages/examples/src/client/simpleClientCredentials.ts new file mode 100644 index 000000000..7defcc41f --- /dev/null +++ b/packages/examples/src/client/simpleClientCredentials.ts @@ -0,0 +1,82 @@ +#!/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 { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../client/auth-extensions.js'; +import { OAuthClientProvider } from '../../client/auth.js'; + +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(); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/examples/src/client/simpleOAuthClient.ts b/packages/examples/src/client/simpleOAuthClient.ts new file mode 100644 index 000000000..8071e61ac --- /dev/null +++ b/packages/examples/src/client/simpleOAuthClient.ts @@ -0,0 +1,458 @@ +#!/usr/bin/env node + +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { URL } from 'node:url'; +import { exec } from 'node:child_process'; +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { OAuthClientMetadata } from '../../shared/auth.js'; +import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '../../types.js'; +import { UnauthorizedError } from '../../client/auth.js'; +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 async openBrowser(url: string): Promise { + console.log(`🌐 Opening browser for authorization: ${url}`); + + const command = `open "${url}"`; + + exec(command, error => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + 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?.substring(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, ListToolsResultSchema); + + if (result.tools && result.tools.length > 0) { + console.log('\n📋 Available tools:'); + result.tools.forEach((tool, index) => { + 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 request: CallToolRequest = { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArgs + } + }; + + const result = await this.client.request(request, CallToolResultSchema); + + console.log(`\n🔧 Tool '${toolName}' result:`); + if (result.content) { + result.content.forEach(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 + }, + CallToolResultSchema, + { + task: { + taskId: `task-${Date.now()}`, + ttl: 60000 + } + } + ); + + // 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!'); + message.result.content.forEach(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(); + } +} + +// Run if this file is executed directly +main().catch(error => { + console.error('Unhandled error:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/simpleOAuthClientProvider.ts b/packages/examples/src/client/simpleOAuthClientProvider.ts new file mode 100644 index 000000000..3f1932c3e --- /dev/null +++ b/packages/examples/src/client/simpleOAuthClientProvider.ts @@ -0,0 +1,66 @@ +import { OAuthClientProvider } from '../../client/auth.js'; +import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; + +/** + * 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 + ) { + 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/packages/examples/src/client/simpleStreamableHttp.ts b/packages/examples/src/client/simpleStreamableHttp.ts new file mode 100644 index 000000000..21ab4f556 --- /dev/null +++ b/packages/examples/src/client/simpleStreamableHttp.ts @@ -0,0 +1,924 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + ListPromptsRequest, + ListPromptsResultSchema, + GetPromptRequest, + GetPromptResultSchema, + ListResourcesRequest, + ListResourcesResultSchema, + LoggingMessageNotificationSchema, + ResourceListChangedNotificationSchema, + ElicitRequestSchema, + ResourceLink, + ReadResourceRequest, + ReadResourceResultSchema, + RELATED_TASK_META_KEY, + ErrorCode, + McpError +} from '../../types.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; +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 = undefined; +let sessionId: string | undefined = 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(' 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 'start-notifications': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? parseInt(args[2], 10) : 10; + await startNotifications(interval, count); + break; + } + + case 'run-notifications-tool-with-resumability': { + const interval = args[1] ? parseInt(args[1], 10) : 2000; + const count = args[2] ? 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 a new client with form elicitation capability + client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + client.onerror = error => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + }; + + // Set up elicitation request handler with proper validation + client.setRequestHandler(ElicitRequestSchema, async request => { + if (request.params.mode !== 'form') { + throw new McpError(ErrorCode.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('Requested Schema:'); + console.log(JSON.stringify(request.params.requestedSchema, null, 2)); + + 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; + } 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; + + if (field.type === 'boolean') { + parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; + } else if (field.type === 'number') { + parsedValue = parseFloat(answer); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid number`); + } + } else if (field.type === 'integer') { + parsedValue = parseInt(answer, 10); + if (isNaN(parsedValue as number)) { + throw new Error(`${fieldName} must be a valid integer`); + } + } else 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; + } + } catch (error) { + console.log(`❌ Error: ${error}`); + // Continue to next attempt + break; + } + } + + if (inputCancelled) { + return { 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 { action: 'decline' }; + } + } + + // Validate the complete object against the schema + const isValid = validate(content); + + if (!isValid) { + console.log('❌ Validation errors:'); + validate.errors?.forEach(error => { + 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 { 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()); + }); + }); + + if (confirmAnswer === 'yes' || confirmAnswer === 'y') { + return { + action: 'accept', + content + }; + } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { + return { action: 'cancel' }; + } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { + if (attempts < maxAttempts) { + console.log('Please re-enter the information...'); + continue; + } else { + return { action: 'decline' }; + } + } + } + + console.log('Maximum attempts reached. Declining request.'); + return { action: 'decline' }; + }); + + transport = new StreamableHTTPClientTransport(new URL(serverUrl), { + sessionId: sessionId + }); + + // Set up notification handlers + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + notificationCount++; + console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); + // Re-display the prompt + process.stdout.write('> '); + }); + + client.setNotificationHandler(ResourceListChangedNotificationSchema, 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: {} + }, + ListResourcesResultSchema + ); + 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('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; + } else { + console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); + console.log('Session ID is still active:', transport.sessionId); + } + } 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, ListToolsResultSchema); + + 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 { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name, + arguments: args + } + }; + + console.log(`Calling tool '${name}' with args:`, args); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + const resourceLinks: ResourceLink[] = []; + + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else if (item.type === '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}`); + } + } else if (item.type === 'resource') { + console.log(` [Embedded Resource: ${item.resource.uri}]`); + } else if (item.type === 'image') { + console.log(` [Image: ${item.mimeType}]`); + } else if (item.type === 'audio') { + console.log(` [Audio: ${item.mimeType}]`); + } else { + 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 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 request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { interval, count } + } + }; + + const onLastEventIdUpdate = (event: string) => { + notificationsToolLastEventId = event; + console.log(`Updated resumption token: ${event}`); + }; + + const result = await client.request(request, CallToolResultSchema, { + resumptionToken: notificationsToolLastEventId, + onresumptiontoken: onLastEventIdUpdate + }); + + console.log('Tool result:'); + result.content.forEach(item => { + 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, ListPromptsResultSchema); + 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, GetPromptResultSchema); + console.log('Prompt template:'); + promptResult.messages.forEach((msg, index) => { + 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, ListResourcesResultSchema); + + 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, ReadResourceResultSchema); + + 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 + }, + CallToolResultSchema, + { + task: { + ttl: 60000 // 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:'); + message.result.content.forEach(item => { + 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!'); + 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 +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/simpleTaskInteractiveClient.ts b/packages/examples/src/client/simpleTaskInteractiveClient.ts new file mode 100644 index 000000000..06ed0ead1 --- /dev/null +++ b/packages/examples/src/client/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 { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; +import { + CallToolResultSchema, + TextContent, + ElicitRequestSchema, + CreateMessageRequestSchema, + CreateMessageRequest, + CreateMessageResult, + ErrorCode, + McpError +} from '../../types.js'; + +// 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: string; 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(ElicitRequestSchema, async request => { + if (request.params.mode && request.params.mode !== 'form') { + throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + return elicitationCallback(request.params); + }); + + // Set up sampling request handler + client.setRequestHandler(CreateMessageRequestSchema, 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' } }, + CallToolResultSchema, + { task: { ttl: 60000 } } + ); + + 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': + console.log(`Result: ${getTextContent(message.result)}`); + 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' } }, + CallToolResultSchema, + { + task: { ttl: 60000 } + } + ); + + 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': + console.log(`Result:\n${getTextContent(message.result)}`); + 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 +run(url).catch(error => { + console.error('Error running client:', error); + process.exit(1); +}); diff --git a/packages/examples/src/client/ssePollingClient.ts b/packages/examples/src/client/ssePollingClient.ts new file mode 100644 index 000000000..ac7bba37d --- /dev/null +++ b/packages/examples/src/client/ssePollingClient.ts @@ -0,0 +1,106 @@ +/** + * 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: npx tsx src/examples/client/ssePollingClient.ts + * Requires: ssePollingExample.ts server running on port 3001 + */ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../types.js'; + +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(LoggingMessageNotificationSchema, 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: {} + } + }, + CallToolResultSchema, + { + // 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'); + } +} + +main().catch(console.error); diff --git a/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts b/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts new file mode 100644 index 000000000..657f48953 --- /dev/null +++ b/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts @@ -0,0 +1,191 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { SSEClientTransport } from '../../client/sse.js'; +import { + ListToolsRequest, + ListToolsResultSchema, + CallToolRequest, + CallToolResultSchema, + LoggingMessageNotificationSchema +} from '../../types.js'; + +/** + * 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(LoggingMessageNotificationSchema, 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); + 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, ListToolsResultSchema); + + 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 { + // Call the notification tool using reasonable defaults + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: 'start-notification-stream', + arguments: { + interval: 1000, // 1 second between notifications + count: 5 // Send 5 notifications + } + } + }; + + console.log('Calling notification tool...'); + const result = await client.request(request, CallToolResultSchema); + + console.log('Tool result:'); + result.content.forEach(item => { + 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 +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/packages/examples/src/server/README-simpleTaskInteractive.md b/packages/examples/src/server/README-simpleTaskInteractive.md new file mode 100644 index 000000000..6e8cd345b --- /dev/null +++ b/packages/examples/src/server/README-simpleTaskInteractive.md @@ -0,0 +1,161 @@ +# 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 the SDK root directory +npx tsx src/examples/server/simpleTaskInteractive.ts + +# Or with a custom port +PORT=9000 npx tsx src/examples/server/simpleTaskInteractive.ts +``` + +The server will start on http://localhost:8000/mcp (or your custom port). + +### Run the Client + +```bash +# From the SDK root directory +npx tsx src/examples/client/simpleTaskInteractiveClient.ts + +# Or connect to a different server +npx tsx src/examples/client/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 + +- `src/shared/task.ts` - Core task interfaces (TaskStore, TaskMessageQueue) +- `src/examples/shared/inMemoryTaskStore.ts` - In-memory implementations +- `src/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.) diff --git a/packages/examples/src/server/demoInMemoryOAuthProvider.ts b/packages/examples/src/server/demoInMemoryOAuthProvider.ts new file mode 100644 index 000000000..1abc040ce --- /dev/null +++ b/packages/examples/src/server/demoInMemoryOAuthProvider.ts @@ -0,0 +1,249 @@ +import { randomUUID } from 'node:crypto'; +import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; +import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; +import express, { Request, Response } from 'express'; +import { AuthInfo } from '../../server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; +import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; +import { InvalidRequestError } from '../../server/auth/errors.js'; + +export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { + private clients = new Map(); + + async getClient(clientId: string) { + return this.clients.get(clientId); + } + + async registerClient(clientMetadata: OAuthClientInformationFull) { + this.clients.set(clientMetadata.client_id, clientMetadata); + return clientMetadata; + } +} + +/** + * 🚨 DEMO ONLY - NOT FOR PRODUCTION + * + * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, + * for example: + * - Persistent token storage + * - Rate limiting + */ +export class DemoInMemoryAuthProvider implements OAuthServerProvider { + clientsStore = new DemoInMemoryClientsStore(); + private codes = new Map< + string, + { + params: AuthorizationParams; + client: OAuthClientInformationFull; + } + >(); + private tokens = new Map(); + + constructor(private validateResource?: (resource?: URL) => boolean) {} + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const code = randomUUID(); + + const searchParams = new URLSearchParams({ + code + }); + if (params.state !== undefined) { + searchParams.set('state', params.state); + } + + this.codes.set(code, { + client, + params + }); + + // Simulate a user login + // Set a secure HTTP-only session cookie with authorization info + if (res.cookie) { + const authCookieData = { + userId: 'demo_user', + name: 'Demo User', + timestamp: Date.now() + }; + res.cookie('demo_session', JSON.stringify(authCookieData), { + httpOnly: true, + secure: false, // In production, this should be true + sameSite: 'lax', + maxAge: 24 * 60 * 60 * 1000, // 24 hours - for demo purposes + path: '/' // Available to all routes + }); + } + + if (!client.redirect_uris.includes(params.redirectUri)) { + throw new InvalidRequestError('Unregistered redirect_uri'); + } + const targetUrl = new URL(params.redirectUri); + targetUrl.search = searchParams.toString(); + res.redirect(targetUrl.toString()); + } + + async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + // Store the challenge with the code data + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + return codeData.params.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + // Note: code verifier is checked in token.ts by default + // it's unused here for that reason. + _codeVerifier?: string + ): Promise { + const codeData = this.codes.get(authorizationCode); + if (!codeData) { + throw new Error('Invalid authorization code'); + } + + if (codeData.client.client_id !== client.client_id) { + throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); + } + + if (this.validateResource && !this.validateResource(codeData.params.resource)) { + throw new Error(`Invalid resource: ${codeData.params.resource}`); + } + + this.codes.delete(authorizationCode); + const token = randomUUID(); + + const tokenData = { + token, + clientId: client.client_id, + scopes: codeData.params.scopes || [], + expiresAt: Date.now() + 3600000, // 1 hour + resource: codeData.params.resource, + type: 'access' + }; + + this.tokens.set(token, tokenData); + + return { + access_token: token, + token_type: 'bearer', + expires_in: 3600, + scope: (codeData.params.scopes || []).join(' ') + }; + } + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + _refreshToken: string, + _scopes?: string[], + _resource?: URL + ): Promise { + throw new Error('Not implemented for example demo'); + } + + async verifyAccessToken(token: string): Promise { + const tokenData = this.tokens.get(token); + if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { + throw new Error('Invalid or expired token'); + } + + return { + token, + clientId: tokenData.clientId, + scopes: tokenData.scopes, + expiresAt: Math.floor(tokenData.expiresAt / 1000), + resource: tokenData.resource + }; + } +} + +export const setupAuthServer = ({ + authServerUrl, + mcpServerUrl, + strictResource +}: { + authServerUrl: URL; + mcpServerUrl: URL; + strictResource: boolean; +}): OAuthMetadata => { + // Create separate auth server app + // NOTE: This is a separate app on a separate port to illustrate + // how to separate an OAuth Authorization Server from a Resource + // server in the SDK. The SDK is not intended to be provide a standalone + // authorization server. + + const validateResource = strictResource + ? (resource?: URL) => { + if (!resource) return false; + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + return resource.toString() === expectedResource.toString(); + } + : undefined; + + const provider = new DemoInMemoryAuthProvider(validateResource); + const authApp = express(); + authApp.use(express.json()); + // For introspection requests + authApp.use(express.urlencoded()); + + // Add OAuth routes to the auth server + // NOTE: this will also add a protected resource metadata route, + // but it won't be used, so leave it. + authApp.use( + mcpAuthRouter({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'] + }) + ); + + authApp.post('/introspect', async (req: Request, res: Response) => { + try { + const { token } = req.body; + if (!token) { + res.status(400).json({ error: 'Token is required' }); + return; + } + + const tokenInfo = await provider.verifyAccessToken(token); + res.json({ + active: true, + client_id: tokenInfo.clientId, + scope: tokenInfo.scopes.join(' '), + exp: tokenInfo.expiresAt, + aud: tokenInfo.resource + }); + return; + } catch (error) { + res.status(401).json({ + active: false, + error: 'Unauthorized', + error_description: `Invalid token: ${error}` + }); + } + }); + + const auth_port = authServerUrl.port; + // Start the auth server + authApp.listen(auth_port, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`OAuth Authorization Server listening on port ${auth_port}`); + }); + + // Note: we could fetch this from the server, but then we end up + // with some top level async which gets annoying. + const oauthMetadata: OAuthMetadata = createOAuthMetadata({ + provider, + issuerUrl: authServerUrl, + scopesSupported: ['mcp:tools'] + }); + + oauthMetadata.introspection_endpoint = new URL('/introspect', authServerUrl).href; + + return oauthMetadata; +}; diff --git a/packages/examples/src/server/elicitationFormExample.ts b/packages/examples/src/server/elicitationFormExample.ts new file mode 100644 index 000000000..6c0800949 --- /dev/null +++ b/packages/examples/src/server/elicitationFormExample.ts @@ -0,0 +1,471 @@ +// Run with: npx tsx src/examples/server/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 { type Request, type Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { isInitializeRequest } from '../../types.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults +// The validator supports format validation (email, date, etc.) if ajv-formats is installed +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', + inputSchema: {} + }, + 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', + inputSchema: {} + }, + 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', + inputSchema: {} + }, + 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 + }; + } + } +); + +async function main() { + const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; + + const app = createMcpExpressApp(); + + // Map to store transports by session ID + const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + + // 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: StreamableHTTPServerTransport; + 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 StreamableHTTPServerTransport({ + 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 the transport to the MCP server BEFORE handling the request + await mcpServer.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + 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: -32603, + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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); + 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); + }); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); diff --git a/packages/examples/src/server/elicitationUrlExample.ts b/packages/examples/src/server/elicitationUrlExample.ts new file mode 100644 index 000000000..5ddecc4e1 --- /dev/null +++ b/packages/examples/src/server/elicitationUrlExample.ts @@ -0,0 +1,771 @@ +// Run with: npx tsx src/examples/server/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 express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/express.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { OAuthMetadata } from '../../shared/auth.js'; +import { checkResourceAllowed } from '../../shared/auth-utils.js'; + +import cors from 'cors'; + +// 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: { + cartId: z.string().describe('The ID of the cart to confirm') + } + }, + async ({ cartId }, extra): 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 = extra.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: { + param1: z.string().describe('First parameter') + } + }, + async (_, extra): 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 `extra.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 = extra.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 ? parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? 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}`); + +const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true }); + +const tokenVerifier = { + verifyAccessToken: async (token: string) => { + const endpoint = oauthMetadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + if (!response.ok) { + const text = await response.text().catch(() => null); + throw new Error(`Invalid or expired token: ${text}`); + } + + const data = await response.json(); + + if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); + } + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); + } + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp + }; + } +}; +// Add metadata routes to the main MCP server +app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server' + }) +); + +authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + 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 \x1b[32m${apiKey}\x1b[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]: StreamableHTTPServerTransport } = {}; + +// 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: StreamableHTTPServerTransport; + 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 StreamableHTTPServerTransport({ + 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 { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + 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: -32603, + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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); + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); +}); + +// 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/packages/examples/src/server/jsonResponseStreamableHttp.ts b/packages/examples/src/server/jsonResponseStreamableHttp.ts new file mode 100644 index 000000000..224955c46 --- /dev/null +++ b/packages/examples/src/server/jsonResponseStreamableHttp.ts @@ -0,0 +1,177 @@ +import { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import * as z from 'zod/v4'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +// 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.tool( + 'greet', + 'A simple greeting tool', + { + 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.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet') + }, + async ({ name }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await server.sendLoggingMessage( + { + level: 'debug', + data: `Starting multi-greet for ${name}` + }, + extra.sessionId + ); + + await sleep(1000); // Wait 1 second before first greeting + + await server.sendLoggingMessage( + { + level: 'info', + data: `Sending first greeting to ${name}` + }, + extra.sessionId + ); + + await sleep(1000); // Wait another second before second greeting + + await server.sendLoggingMessage( + { + level: 'info', + data: `Sending second greeting to ${name}` + }, + extra.sessionId + ); + + return { + content: [ + { + type: 'text', + text: `Good morning, ${name}!` + } + ] + }; + } + ); + return server; +}; + +const app = createMcpExpressApp(); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +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: StreamableHTTPServerTransport; + + 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 StreamableHTTPServerTransport({ + 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 { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + 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: -32603, + 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); + 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/packages/examples/src/server/mcpServerOutputSchema.ts b/packages/examples/src/server/mcpServerOutputSchema.ts new file mode 100644 index 000000000..7ef9f6227 --- /dev/null +++ b/packages/examples/src/server/mcpServerOutputSchema.ts @@ -0,0 +1,80 @@ +#!/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 '../../server/mcp.js'; +import { StdioServerTransport } from '../../server/stdio.js'; +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: { + city: z.string().describe('City name'), + country: z.string().describe('Country code (e.g., US, UK)') + }, + outputSchema: { + 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'); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); diff --git a/packages/examples/src/server/simpleSseServer.ts b/packages/examples/src/server/simpleSseServer.ts new file mode 100644 index 000000000..1cd10cd2d --- /dev/null +++ b/packages/examples/src/server/simpleSseServer.ts @@ -0,0 +1,174 @@ +import { Request, Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import * as z from 'zod/v4'; +import { CallToolResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +/** + * This example server demonstrates the deprecated HTTP+SSE transport + * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. + * + * The server exposes two endpoints: + * - /mcp: For establishing the SSE stream (GET) + * - /messages: For receiving client messages (POST) + * + */ + +// Create an MCP server instance +const getServer = () => { + const server = new McpServer( + { + name: 'simple-sse-server', + version: '1.0.0' + }, + { capabilities: { logging: {} } } + ); + + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications', + { + interval: z.number().describe('Interval in milliseconds between notifications').default(1000), + count: z.number().describe('Number of notifications to send').default(10) + }, + async ({ interval, count }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + // Send the initial notification + await server.sendLoggingMessage( + { + level: 'info', + data: `Starting notification stream with ${count} messages every ${interval}ms` + }, + extra.sessionId + ); + + // Send periodic notifications + while (counter < count) { + counter++; + await sleep(interval); + + try { + await server.sendLoggingMessage( + { + level: 'info', + data: `Notification #${counter} at ${new Date().toISOString()}` + }, + extra.sessionId + ); + } catch (error) { + console.error('Error sending notification:', error); + } + } + + return { + content: [ + { + type: 'text', + text: `Completed sending ${count} notifications every ${interval}ms` + } + ] + }; + } + ); + return server; +}; + +const app = createMcpExpressApp(); + +// Store transports by session ID +const transports: Record = {}; + +// SSE endpoint for establishing the stream +app.get('/mcp', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (establishing SSE stream)'); + + try { + // Create a new SSE transport for the client + // The endpoint for POST messages is '/messages' + const transport = new SSEServerTransport('/messages', res); + + // Store the transport by session ID + const sessionId = transport.sessionId; + transports[sessionId] = transport; + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + console.log(`SSE transport closed for session ${sessionId}`); + delete transports[sessionId]; + }; + + // Connect the transport to the MCP server + const server = getServer(); + await server.connect(transport); + + console.log(`Established SSE stream with session ID: ${sessionId}`); + } catch (error) { + console.error('Error establishing SSE stream:', error); + if (!res.headersSent) { + res.status(500).send('Error establishing SSE stream'); + } + } +}); + +// Messages endpoint for receiving client JSON-RPC requests +app.post('/messages', async (req: Request, res: Response) => { + console.log('Received POST request to /messages'); + + // Extract session ID from URL query parameter + // In the SSE protocol, this is added by the client based on the endpoint event + const sessionId = req.query.sessionId as string | undefined; + + if (!sessionId) { + console.error('No session ID provided in request URL'); + res.status(400).send('Missing sessionId parameter'); + return; + } + + const transport = transports[sessionId]; + if (!transport) { + console.error(`No active transport found for session ID: ${sessionId}`); + res.status(404).send('Session not found'); + return; + } + + try { + // Handle the POST message with the transport + await transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) { + res.status(500).send('Error handling request'); + } + } +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); +}); + +// 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/packages/examples/src/server/simpleStatelessStreamableHttp.ts b/packages/examples/src/server/simpleStatelessStreamableHttp.ts new file mode 100644 index 000000000..748d82fda --- /dev/null +++ b/packages/examples/src/server/simpleStatelessStreamableHttp.ts @@ -0,0 +1,171 @@ +import { Request, Response } from 'express'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import * as z from 'zod/v4'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +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.prompt( + 'greeting-template', + 'A simple greeting prompt template', + { + 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.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + 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 }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await server.sendLoggingMessage( + { + level: 'info', + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, + extra.sessionId + ); + } 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.resource( + '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: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ + 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: -32603, + 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: -32000, + 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: -32000, + 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); + 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...'); + process.exit(0); +}); diff --git a/packages/examples/src/server/simpleStreamableHttp.ts b/packages/examples/src/server/simpleStreamableHttp.ts new file mode 100644 index 000000000..ca1363198 --- /dev/null +++ b/packages/examples/src/server/simpleStreamableHttp.ts @@ -0,0 +1,751 @@ +import { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import * as z from 'zod/v4'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; +import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { createMcpExpressApp } from '../../server/express.js'; +import { + CallToolResult, + ElicitResultSchema, + GetPromptResult, + isInitializeRequest, + PrimitiveSchemaDefinition, + ReadResourceResult, + ResourceLink +} from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../experimental/tasks/stores/in-memory.js'; +import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { OAuthMetadata } from '../../shared/auth.js'; +import { checkResourceAllowed } from '../../shared/auth-utils.js'; + +// Check for OAuth flag +const useOAuth = process.argv.includes('--oauth'); +const strictOAuth = process.argv.includes('--oauth-strict'); + +// 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, // Enable task support + 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: { + 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.tool( + 'multi-greet', + 'A tool that sends different greetings with delays between them', + { + name: z.string().describe('Name to greet') + }, + { + title: 'Multiple Greeting Tool', + readOnlyHint: true, + openWorldHint: false + }, + async ({ name }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + await server.sendLoggingMessage( + { + level: 'debug', + data: `Starting multi-greet for ${name}` + }, + extra.sessionId + ); + + await sleep(1000); // Wait 1 second before first greeting + + await server.sendLoggingMessage( + { + level: 'info', + data: `Sending first greeting to ${name}` + }, + extra.sessionId + ); + + await sleep(1000); // Wait another second before second greeting + + await server.sendLoggingMessage( + { + level: 'info', + data: `Sending second greeting to ${name}` + }, + extra.sessionId + ); + + 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.tool( + 'collect-user-info', + 'A tool that collects user information through form elicitation', + { + infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') + }, + async ({ infoType }, extra): 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 extra parameter to elicit input + const result = await extra.sendRequest( + { + method: 'elicitation/create', + params: { + mode: 'form', + message, + requestedSchema + } + }, + ElicitResultSchema + ); + + 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: { + 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.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + 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 }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await server.sendLoggingMessage( + { + level: 'info', + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, + extra.sessionId + ); + } 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: { + 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: { + duration: z.number().describe('Duration in milliseconds').default(5000) + } + }, + { + async createTask({ duration }, { taskStore, taskRequestedTtl }) { + // Create the task + const task = await taskStore.createTask({ + ttl: taskRequestedTtl + }); + + // Simulate out-of-band work + (async () => { + await new Promise(resolve => setTimeout(resolve, duration)); + await taskStore.storeTaskResult(task.taskId, 'completed', { + content: [ + { + type: 'text', + text: `Completed ${duration}ms delay` + } + ] + }); + })(); + + // Return CreateTaskResult with the created task + return { + task + }; + }, + async getTask(_args, { taskId, taskStore }) { + return await taskStore.getTask(taskId); + }, + async getTaskResult(_args, { taskId, taskStore }) { + const result = await taskStore.getTaskResult(taskId); + return result as CallToolResult; + } + } + ); + + return server; +}; + +const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; +const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; + +const app = createMcpExpressApp(); + +// 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}`); + + const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth }); + + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + const endpoint = oauthMetadata.introspection_endpoint; + + if (!endpoint) { + throw new Error('No token verification endpoint available in metadata'); + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + if (!response.ok) { + const text = await response.text().catch(() => null); + throw new Error(`Invalid or expired token: ${text}`); + } + + const data = await response.json(); + + if (strictOAuth) { + if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); + } + if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); + } + } + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp + }; + } + }; + // Add metadata routes to the main MCP server + app.use( + mcpAuthMetadataRouter({ + oauthMetadata, + resourceServerUrl: mcpServerUrl, + scopesSupported: ['mcp:tools'], + resourceName: 'MCP Demo Server' + }) + ); + + authMiddleware = requireBearerAuth({ + verifier: tokenVerifier, + requiredScopes: [], + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + }); +} + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +// 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: StreamableHTTPServerTransport; + 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 StreamableHTTPServerTransport({ + 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 { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + 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: -32603, + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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); + process.exit(1); + } + console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); +}); + +// 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/packages/examples/src/server/simpleTaskInteractive.ts b/packages/examples/src/server/simpleTaskInteractive.ts new file mode 100644 index 000000000..db0a4b579 --- /dev/null +++ b/packages/examples/src/server/simpleTaskInteractive.ts @@ -0,0 +1,745 @@ +/** + * 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 { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { Server } from '../../server/index.js'; +import { createMcpExpressApp } from '../../server/express.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { + CallToolResult, + CreateTaskResult, + GetTaskResult, + Tool, + TextContent, + RELATED_TASK_META_KEY, + Task, + Result, + RequestId, + JSONRPCRequest, + SamplingMessage, + ElicitRequestFormParams, + CreateMessageRequest, + ElicitResult, + CreateMessageResult, + PrimitiveSchemaDefinition, + ListToolsRequestSchema, + CallToolRequestSchema, + GetTaskRequestSchema, + GetTaskPayloadRequestSchema, + GetTaskPayloadResult +} from '../../types.js'; +import { TaskMessageQueue, QueuedMessage, QueuedRequest, isTerminal, CreateTaskOptions } from '../../experimental/tasks/interfaces.js'; +import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; + +// ============================================================================ +// 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)[]>(); + + async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { + await super.updateTaskStatus(taskId, status, statusMessage, sessionId); + this.notifyUpdate(taskId); + } + + 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 ? 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(ListToolsRequestSchema, 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(CallToolRequestSchema, async (request, extra): 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, extra.requestId, request, extra.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.substring(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: extra.sessionId ?? '' + }); + + return { task }; + }); + + // Handle tasks/get + server.setRequestHandler(GetTaskRequestSchema, 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(GetTaskPayloadRequestSchema, async (request, extra): Promise => { + const { taskId } = request.params; + console.log(`[Server] tasks/result called for task ${taskId}`); + return taskResultHandler.handle(taskId, server, extra.sessionId ?? ''); + }); + + return server; +}; + +// ============================================================================ +// Express App Setup +// ============================================================================ + +const app = createMcpExpressApp(); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +// 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: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + 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 { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Bad Request: No valid session ID' }, + 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: -32603, 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts new file mode 100644 index 000000000..5c91b7e33 --- /dev/null +++ b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts @@ -0,0 +1,251 @@ +import { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { SSEServerTransport } from '../../server/sse.js'; +import * as z from 'zod/v4'; +import { CallToolResult, isInitializeRequest } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +/** + * This example server demonstrates backwards compatibility with both: + * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) + * 2. The Streamable HTTP transport (protocol version 2025-03-26) + * + * It maintains a single MCP server instance but exposes two transport options: + * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) + * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) + * - /messages: The deprecated POST endpoint for older clients (POST to send messages) + */ + +const getServer = () => { + const server = new McpServer( + { + name: 'backwards-compatible-server', + version: '1.0.0' + }, + { capabilities: { logging: {} } } + ); + + // Register a simple tool that sends notifications over time + server.tool( + 'start-notification-stream', + 'Starts sending periodic notifications for testing resumability', + { + 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 }, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + let counter = 0; + + while (count === 0 || counter < count) { + counter++; + try { + await server.sendLoggingMessage( + { + level: 'info', + data: `Periodic notification #${counter} at ${new Date().toISOString()}` + }, + extra.sessionId + ); + } 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` + } + ] + }; + } + ); + return server; +}; + +// Create Express application +const app = createMcpExpressApp(); + +// Store transports by session ID +const transports: Record = {}; + +//============================================================================= +// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) +//============================================================================= + +// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint +app.all('/mcp', async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Check if the transport is of the correct type + const existingTransport = transports[sessionId]; + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol' + }, + id: null + }); + return; + } + } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: sessionId => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP 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 + const server = getServer(); + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + id: null + }); + return; + } + + // Handle the request with the 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: -32603, + message: 'Internal server error' + }, + id: null + }); + } + } +}); + +//============================================================================= +// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) +//============================================================================= + +app.get('/sse', async (req: Request, res: Response) => { + console.log('Received GET request to /sse (deprecated SSE transport)'); + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + const server = getServer(); + await server.connect(transport); +}); + +app.post('/messages', async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + let transport: SSEServerTransport; + const existingTransport = transports[sessionId]; + if (existingTransport instanceof SSEServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Session exists but uses a different transport protocol' + }, + id: null + }); + return; + } + if (transport) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send('No transport found for sessionId'); + } +}); + +// Start the server +const PORT = 3000; +app.listen(PORT, error => { + if (error) { + console.error('Failed to start server:', error); + process.exit(1); + } + console.log(`Backwards compatible MCP server listening on port ${PORT}`); + console.log(` +============================================== +SUPPORTED TRANSPORT OPTIONS: + +1. Streamable Http(Protocol version: 2025-03-26) + Endpoint: /mcp + Methods: GET, POST, DELETE + Usage: + - Initialize with POST to /mcp + - Establish SSE stream with GET to /mcp + - Send requests with POST to /mcp + - Terminate session with DELETE to /mcp + +2. Http + SSE (Protocol version: 2024-11-05) + Endpoints: /sse (GET) and /messages (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /messages?sessionId= +============================================== +`); +}); + +// 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/packages/examples/src/server/ssePollingExample.ts b/packages/examples/src/server/ssePollingExample.ts new file mode 100644 index 000000000..bbecf2fdb --- /dev/null +++ b/packages/examples/src/server/ssePollingExample.ts @@ -0,0 +1,151 @@ +/** + * 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 `extra.closeSSEStream()` callback to gracefully disconnect clients mid-operation + * + * Run with: npx tsx src/examples/server/ssePollingExample.ts + * Test with: curl or the MCP Inspector + */ +import { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/express.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { CallToolResult } from '../../types.js'; +import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import cors from 'cors'; + +// Create the MCP server +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.tool( + 'long-task', + 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', + {}, + async (_args, extra): Promise => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + console.log(`[${extra.sessionId}] Starting long-task...`); + + // Send first progress notification + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 25% - Starting work...' + }, + extra.sessionId + ); + await sleep(1000); + + // Send second progress notification + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 50% - Halfway there...' + }, + extra.sessionId + ); + 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 extra.closeSSEStream callback - available when eventStore is configured + if (extra.closeSSEStream) { + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + extra.closeSSEStream(); + } + + // Continue processing while client is disconnected + // Events are stored in eventStore and will be replayed on reconnect + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 75% - Almost done (sent while client disconnected)...' + }, + extra.sessionId + ); + + await sleep(500); + await server.sendLoggingMessage( + { + level: 'info', + data: 'Progress: 100% - Complete!' + }, + extra.sessionId + ); + + console.log(`[${extra.sessionId}] Task complete`); + + return { + content: [ + { + type: 'text', + text: 'Long task completed successfully!' + } + ] + }; + } +); + +// 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 StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + retryInterval: 2000, // Default retry interval for priming events + onsessioninitialized: id => { + console.log(`[${id}] Session initialized`); + transports.set(id, transport!); + } + }); + + // Connect the MCP server to the transport + 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/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts b/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts new file mode 100644 index 000000000..546d35c70 --- /dev/null +++ b/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts @@ -0,0 +1,127 @@ +import { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/express.js'; + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'resource-list-changed-notification-server', + version: '1.0.0' +}); + +// Store transports by session ID to send notifications +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const addResource = (name: string, content: string) => { + const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; + server.resource( + name, + uri, + { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, + async (): Promise => { + return { + contents: [{ uri, text: content }] + }; + } + ); +}; + +addResource('example-resource', 'Initial content for example-resource'); + +const resourceChangeInterval = setInterval(() => { + const name = randomUUID(); + addResource(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: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + 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 + 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 { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided' + }, + 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: -32603, + 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 || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + 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); + process.exit(1); + } + console.log(`Server listening on port ${PORT}`); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + clearInterval(resourceChangeInterval); + await server.close(); + process.exit(0); +}); diff --git a/packages/examples/src/server/toolWithSampleServer.ts b/packages/examples/src/server/toolWithSampleServer.ts new file mode 100644 index 000000000..e6d733598 --- /dev/null +++ b/packages/examples/src/server/toolWithSampleServer.ts @@ -0,0 +1,57 @@ +// Run with: npx tsx src/examples/server/toolWithSampleServer.ts + +import { McpServer } from '../../server/mcp.js'; +import { StdioServerTransport } from '../../server/stdio.js'; +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: { + 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...'); +} + +main().catch(error => { + console.error('Server error:', error); + process.exit(1); +}); diff --git a/packages/examples/src/shared/inMemoryEventStore.ts b/packages/examples/src/shared/inMemoryEventStore.ts new file mode 100644 index 000000000..d4d02eb91 --- /dev/null +++ b/packages/examples/src/shared/inMemoryEventStore.ts @@ -0,0 +1,78 @@ +import { JSONRPCMessage } from '../../types.js'; +import { EventStore } from '../../server/streamableHttp.js'; + +/** + * 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).substring(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()].sort((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/packages/examples/tsconfig.json b/packages/examples/tsconfig.json new file mode 100644 index 000000000..4e641e6a3 --- /dev/null +++ b/packages/examples/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], + "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"] + } + } +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 000000000..0b143d2f4 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@modelcontextprotocol/sdk-server", + "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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/*" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "typecheck": "tsgo --noEmit", + "build": "npm run build:esm", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/shared": "workspace:^", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@cfworker/json-schema": "^4.1.1", + "@eslint/js": "^9.39.1", + "@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.0", + "@types/express-serve-static-core": "^5.1.0", + "@types/node": "^22.12.0", + "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.12", + "@typescript/native-preview": "^7.0.0-dev.20251103.1", + "eslint": "^9.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.23.1", + "prettier": "3.6.2", + "supertest": "^7.0.0", + "tsx": "^4.16.5", + "typescript": "^5.5.4", + "typescript-eslint": "^8.48.1", + "vitest": "^4.0.8", + "ws": "^8.18.0" + } +} diff --git a/packages/server/src/experimental/index.ts b/packages/server/src/experimental/index.ts new file mode 100644 index 000000000..55dd44ed0 --- /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/helpers.ts b/packages/server/src/experimental/tasks/helpers.ts new file mode 100644 index 000000000..34b15188f --- /dev/null +++ b/packages/server/src/experimental/tasks/helpers.ts @@ -0,0 +1,88 @@ +/** + * Experimental task capability assertion helpers. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +/** + * 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 by Client.assertTaskCapability and Server.assertTaskHandlerCapability. + * + * @param requests - The task requests capability object + * @param method - The method being checked + * @param entityName - 'Server' or 'Client' for error messages + * @throws Error if the capability is not supported + * + * @experimental + */ +export function assertToolsCallTaskCapability( + requests: TaskRequestsCapability | undefined, + method: string, + entityName: 'Server' | 'Client' +): void { + if (!requests) { + throw new Error(`${entityName} does not support task creation (required for ${method})`); + } + + switch (method) { + case 'tools/call': + if (!requests.tools?.call) { + throw new Error(`${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 by Server.assertTaskCapability and Client.assertTaskHandlerCapability. + * + * @param requests - The task requests capability object + * @param method - The method being checked + * @param entityName - 'Server' or 'Client' for error messages + * @throws Error if the capability is not supported + * + * @experimental + */ +export function assertClientRequestTaskCapability( + requests: TaskRequestsCapability | undefined, + method: string, + entityName: 'Server' | 'Client' +): void { + if (!requests) { + throw new Error(`${entityName} does not support task creation (required for ${method})`); + } + + switch (method) { + case 'sampling/createMessage': + if (!requests.sampling?.createMessage) { + throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`); + } + break; + + case 'elicitation/create': + if (!requests.elicitation?.create) { + throw new Error(`${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/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts new file mode 100644 index 000000000..8e973e587 --- /dev/null +++ b/packages/server/src/experimental/tasks/index.ts @@ -0,0 +1,19 @@ +/** + * Experimental task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +// SDK implementation interfaces +export * from './interfaces.js'; + +// Assertion helpers +export * from './helpers.js'; + +// Wrapper classes +export * from './server.js'; +export * from './mcp-server.js'; + +// Store implementations +export * from './stores/in-memory.js'; \ No newline at end of file diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts new file mode 100644 index 000000000..bc0708f1d --- /dev/null +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -0,0 +1,276 @@ +/** + * Experimental task interfaces for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + */ + +import { + Task, + RequestId, + Result, + JSONRPCRequest, + JSONRPCNotification, + JSONRPCResultResponse, + JSONRPCErrorResponse, + ServerRequest, + ServerNotification, + CallToolResult, + GetTaskResult, + ToolExecution, + Request +} from '@modelcontextprotocol/shared'; +import { CreateTaskResult } from '@modelcontextprotocol/shared'; +import type { RequestHandlerExtra, RequestTaskStore } from '@modelcontextprotocol/shared'; +import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; +import { BaseToolCallback } from 'src/server/mcp.js'; + +// ============================================================================ +// Task Handler Types (for registerToolTask) +// ============================================================================ + +/** + * Extended handler extra with task store for task creation. + * @experimental + */ +export interface CreateTaskRequestHandlerExtra extends RequestHandlerExtra { + taskStore: RequestTaskStore; +} + +/** + * Extended handler extra with task ID and store for task operations. + * @experimental + */ +export interface TaskRequestHandlerExtra extends RequestHandlerExtra { + taskId: string; + taskStore: RequestTaskStore; +} + +/** + * Handler for creating a task. + * @experimental + */ +export type CreateTaskRequestHandler< + SendResultT extends Result, + Args extends undefined | ZodRawShapeCompat | AnySchema = undefined +> = BaseToolCallback; + +/** + * Handler for task operations (get, getResult). + * @experimental + */ +export type TaskRequestHandler< + SendResultT extends Result, + Args extends undefined | ZodRawShapeCompat | AnySchema = undefined +> = BaseToolCallback; + +/** + * Interface for task-based tool handlers. + * @experimental + */ +export interface ToolTaskHandler { + createTask: CreateTaskRequestHandler; + getTask: TaskRequestHandler; + getTaskResult: TaskRequestHandler; +} + +/** + * 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 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 QueuedMessage must be JSON-serializable. + * + * @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 { + /** + * Time in milliseconds to keep task results available after completion. + * 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 Transport, this allows pluggable task storage implementations + * (in-memory, database, distributed cache, etc.). + * + * @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 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 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 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/server/src/experimental/tasks/mcp-server.ts b/packages/server/src/experimental/tasks/mcp-server.ts new file mode 100644 index 000000000..781107430 --- /dev/null +++ b/packages/server/src/experimental/tasks/mcp-server.ts @@ -0,0 +1,142 @@ +/** + * Experimental McpServer task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; +import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; +import type { ToolAnnotations, ToolExecution } from '@modelcontextprotocol/shared'; +import type { ToolTaskHandler, TaskToolExecution } from './interfaces.js'; + +/** + * Internal interface for accessing McpServer's private _createRegisteredTool method. + * @internal + */ +interface McpServerInternal { + _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: ZodRawShapeCompat | AnySchema | undefined, + outputSchema: ZodRawShapeCompat | AnySchema | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool; +} + +/** + * Experimental task features for 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 `createTask`, `getTask`, and `getTaskResult` + * methods. + * + * @example + * ```typescript + * server.experimental.tasks.registerToolTask('long-computation', { + * description: 'Performs a long computation', + * inputSchema: { input: z.string() }, + * execution: { taskSupport: 'required' } + * }, { + * createTask: async (args, extra) => { + * const task = await extra.taskStore.createTask({ ttl: 300000 }); + * startBackgroundWork(task.taskId, args); + * return { task }; + * }, + * getTask: async (args, extra) => { + * return extra.taskStore.getTask(extra.taskId); + * }, + * getTaskResult: async (args, extra) => { + * return extra.taskStore.getTaskResult(extra.taskId); + * } + * }); + * ``` + * + * @param name - The tool name + * @param config - Tool configuration (description, schemas, etc.) + * @param handler - Task handler with createTask, getTask, getTaskResult methods + * @returns 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< + InputArgs extends undefined | ZodRawShapeCompat | AnySchema, + OutputArgs extends undefined | ZodRawShapeCompat | AnySchema + >( + 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 000000000..3db65b7c9 --- /dev/null +++ b/packages/server/src/experimental/tasks/server.ts @@ -0,0 +1,131 @@ +/** + * Experimental server task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import type { Server } from '../../server/server.js'; +import type { RequestOptions } from '@modelcontextprotocol/shared'; +import type { ResponseMessage } from '@modelcontextprotocol/shared'; +import type { AnySchema, SchemaOutput } from '@modelcontextprotocol/shared'; +import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/shared'; + +/** + * Experimental task features for low-level MCP servers. + * + * Access via `server.experimental.tasks`: + * ```typescript + * const stream = server.experimental.tasks.requestStream(request, schema, options); + * ``` + * + * For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead. + * + * @experimental + */ +export class ExperimentalServerTasks< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> { + constructor(private readonly _server: Server) {} + + /** + * 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 + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + requestStream( + request: ServerRequest | RequestT, + resultSchema: T, + options?: RequestOptions + ): AsyncGenerator>, void, void> { + // Delegate to the server's underlying Protocol method + type ServerWithRequestStream = { + requestStream( + request: ServerRequest | RequestT, + resultSchema: U, + options?: RequestOptions + ): AsyncGenerator>, void, void>; + }; + return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); + } + + /** + * 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 { + type ServerWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise }; + return (this._server as unknown as ServerWithGetTask).getTask({ taskId }, options); + } + + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options + * @returns The task result + * + * @experimental + */ + async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { + return ( + this._server as unknown as { + getTaskResult: ( + params: { taskId: string }, + resultSchema?: U, + options?: RequestOptions + ) => Promise>; + } + ).getTaskResult({ taskId }, resultSchema, 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._server as unknown as { + listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise; + } + ).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._server as unknown as { + cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise; + } + ).cancelTask({ taskId }, options); + } +} diff --git a/packages/server/src/experimental/tasks/stores/in-memory.ts b/packages/server/src/experimental/tasks/stores/in-memory.ts new file mode 100644 index 000000000..aff3ad910 --- /dev/null +++ b/packages/server/src/experimental/tasks/stores/in-memory.ts @@ -0,0 +1,295 @@ +/** + * In-memory implementations of TaskStore and TaskMessageQueue. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ + +import { Task, RequestId, Result, Request } from '../../../types.js'; +import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; +import { randomBytes } from 'node:crypto'; + +interface StoredTask { + task: Task; + request: Request; + requestId: RequestId; + result?: Result; +} + +/** + * A simple in-memory implementation of TaskStore for demonstration purposes. + * + * This implementation stores all tasks in memory and provides automatic cleanup + * based on the ttl duration specified in the task creation parameters. + * + * Note: This is not suitable for production use as all data is lost on restart. + * For production, consider implementing TaskStore with a database or distributed cache. + * + * @experimental + */ +export class InMemoryTaskStore implements TaskStore { + private tasks = new Map(); + private cleanupTimers = new Map>(); + + /** + * Generates a unique task ID. + * Uses 16 bytes of random data encoded as hex (32 characters). + */ + private generateTaskId(): string { + return randomBytes(16).toString('hex'); + } + + 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 + }); + + // 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; + } + + async getTask(taskId: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + return stored ? { ...stored.task } : null; + } + + async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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); + } + } + + async getTaskResult(taskId: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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; + } + + async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise { + const stored = this.tasks.get(taskId); + 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); + } + } + + async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { + const PAGE_SIZE = 10; + const allTaskIds = Array.from(this.tasks.keys()); + + let startIndex = 0; + if (cursor) { + const cursorIndex = allTaskIds.indexOf(cursor); + if (cursorIndex >= 0) { + startIndex = cursorIndex + 1; + } else { + // Invalid cursor - throw error + throw new Error(`Invalid cursor: ${cursor}`); + } + } + + const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE); + const tasks = pageTaskIds.map(taskId => { + const stored = this.tasks.get(taskId)!; + return { ...stored.task }; + }); + + const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 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 Array.from(this.tasks.values()).map(stored => ({ ...stored.task })); + } +} + +/** + * A simple in-memory implementation of TaskMessageQueue for demonstration purposes. + * + * This implementation stores messages in memory, organized by task ID and optional session ID. + * Messages are stored in FIFO queues per task. + * + * Note: This is not suitable for production use in distributed systems. + * For production, consider implementing TaskMessageQueue with Redis or other distributed queues. + * + * @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/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 000000000..9a0fe413e --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,44 @@ +export * from './server/completable.js'; +export * from './server/express.js'; +export * from './server/mcp.js'; +export * from './server/server.js'; +export * from './server/sse.js'; +export * from './server/stdio.js'; +export * from './server/streamableHttp.js'; + +export * from './validation/ajv-provider.js'; +export * from './validation/cfworker-provider.js'; +export * from './experimental/tasks/index.js'; + +// re-export shared types +export * from '@modelcontextprotocol/shared'; +/** + * JSON Schema validation + * + * This module provides configurable JSON Schema validation for the MCP SDK. + * Choose a validator based on your runtime environment: + * + * - AjvJsonSchemaValidator: Best for Node.js (default, fastest) + * Import from: @modelcontextprotocol/sdk/validation/ajv + * Requires peer dependencies: ajv, ajv-formats + * + * - CfWorkerJsonSchemaValidator: Best for edge runtimes + * Import from: @modelcontextprotocol/sdk/validation/cfworker + * Requires peer dependency: @cfworker/json-schema + * + * @example + * ```typescript + * // For Node.js with AJV + * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * const validator = new AjvJsonSchemaValidator(); + * + * // For Cloudflare Workers + * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; + * const validator = new CfWorkerJsonSchemaValidator(); + * ``` + * + * @module validation + */ + +// Core types only - implementations are exported via separate entry points +export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './validation/types.js'; diff --git a/packages/server/src/server/auth/clients.ts b/packages/server/src/server/auth/clients.ts new file mode 100644 index 000000000..4e3f8e17e --- /dev/null +++ b/packages/server/src/server/auth/clients.ts @@ -0,0 +1,22 @@ +import { OAuthClientInformationFull } from '../../shared/auth.js'; + +/** + * Stores information about registered OAuth clients for this server. + */ +export interface OAuthRegisteredClientsStore { + /** + * Returns information about a registered client, based on its ID. + */ + getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; + + /** + * Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server. + * + * NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well. + * + * If unimplemented, dynamic client registration is unsupported. + */ + registerClient?( + client: Omit + ): OAuthClientInformationFull | Promise; +} diff --git a/packages/server/src/server/auth/errors.ts b/packages/server/src/server/auth/errors.ts new file mode 100644 index 000000000..dff413e38 --- /dev/null +++ b/packages/server/src/server/auth/errors.ts @@ -0,0 +1,212 @@ +import { OAuthErrorResponse } from '../../shared/auth.js'; + +/** + * Base class for all OAuth errors + */ +export class OAuthError extends Error { + static errorCode: string; + + constructor( + message: string, + public readonly errorUri?: string + ) { + super(message); + this.name = this.constructor.name; + } + + /** + * Converts the error to a standard OAuth error response object + */ + toResponseObject(): OAuthErrorResponse { + const response: OAuthErrorResponse = { + error: this.errorCode, + error_description: this.message + }; + + if (this.errorUri) { + response.error_uri = this.errorUri; + } + + return response; + } + + get errorCode(): string { + return (this.constructor as typeof OAuthError).errorCode; + } +} + +/** + * Invalid request error - The request is missing a required parameter, + * includes an invalid parameter value, includes a parameter more than once, + * or is otherwise malformed. + */ +export class InvalidRequestError extends OAuthError { + static errorCode = 'invalid_request'; +} + +/** + * Invalid client error - Client authentication failed (e.g., unknown client, no client + * authentication included, or unsupported authentication method). + */ +export class InvalidClientError extends OAuthError { + static errorCode = 'invalid_client'; +} + +/** + * Invalid grant error - 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. + */ +export class InvalidGrantError extends OAuthError { + static errorCode = 'invalid_grant'; +} + +/** + * Unauthorized client error - The authenticated client is not authorized to use + * this authorization grant type. + */ +export class UnauthorizedClientError extends OAuthError { + static errorCode = 'unauthorized_client'; +} + +/** + * Unsupported grant type error - The authorization grant type is not supported + * by the authorization server. + */ +export class UnsupportedGrantTypeError extends OAuthError { + static errorCode = 'unsupported_grant_type'; +} + +/** + * Invalid scope error - The requested scope is invalid, unknown, malformed, or + * exceeds the scope granted by the resource owner. + */ +export class InvalidScopeError extends OAuthError { + static errorCode = 'invalid_scope'; +} + +/** + * Access denied error - The resource owner or authorization server denied the request. + */ +export class AccessDeniedError extends OAuthError { + static errorCode = 'access_denied'; +} + +/** + * Server error - The authorization server encountered an unexpected condition + * that prevented it from fulfilling the request. + */ +export class ServerError extends OAuthError { + static errorCode = 'server_error'; +} + +/** + * Temporarily unavailable error - The authorization server is currently unable to + * handle the request due to a temporary overloading or maintenance of the server. + */ +export class TemporarilyUnavailableError extends OAuthError { + static errorCode = 'temporarily_unavailable'; +} + +/** + * Unsupported response type error - The authorization server does not support + * obtaining an authorization code using this method. + */ +export class UnsupportedResponseTypeError extends OAuthError { + static errorCode = 'unsupported_response_type'; +} + +/** + * Unsupported token type error - The authorization server does not support + * the requested token type. + */ +export class UnsupportedTokenTypeError extends OAuthError { + static errorCode = 'unsupported_token_type'; +} + +/** + * Invalid token error - The access token provided is expired, revoked, malformed, + * or invalid for other reasons. + */ +export class InvalidTokenError extends OAuthError { + static errorCode = 'invalid_token'; +} + +/** + * Method not allowed error - The HTTP method used is not allowed for this endpoint. + * (Custom, non-standard error) + */ +export class MethodNotAllowedError extends OAuthError { + static errorCode = 'method_not_allowed'; +} + +/** + * Too many requests error - Rate limit exceeded. + * (Custom, non-standard error based on RFC 6585) + */ +export class TooManyRequestsError extends OAuthError { + static errorCode = 'too_many_requests'; +} + +/** + * Invalid client metadata error - The client metadata is invalid. + * (Custom error for dynamic client registration - RFC 7591) + */ +export class InvalidClientMetadataError extends OAuthError { + static errorCode = 'invalid_client_metadata'; +} + +/** + * Insufficient scope error - The request requires higher privileges than provided by the access token. + */ +export class InsufficientScopeError extends OAuthError { + static errorCode = 'insufficient_scope'; +} + +/** + * Invalid target error - The requested resource is invalid, missing, unknown, or malformed. + * (Custom error for resource indicators - RFC 8707) + */ +export class InvalidTargetError extends OAuthError { + static errorCode = 'invalid_target'; +} + +/** + * A utility class for defining one-off error codes + */ +export class CustomOAuthError extends OAuthError { + constructor( + private readonly customErrorCode: string, + message: string, + errorUri?: string + ) { + super(message, errorUri); + } + + get errorCode(): string { + return this.customErrorCode; + } +} + +/** + * A full list of all OAuthErrors, enabling parsing from error responses + */ +export const OAUTH_ERRORS = { + [InvalidRequestError.errorCode]: InvalidRequestError, + [InvalidClientError.errorCode]: InvalidClientError, + [InvalidGrantError.errorCode]: InvalidGrantError, + [UnauthorizedClientError.errorCode]: UnauthorizedClientError, + [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, + [InvalidScopeError.errorCode]: InvalidScopeError, + [AccessDeniedError.errorCode]: AccessDeniedError, + [ServerError.errorCode]: ServerError, + [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError, + [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError, + [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError, + [InvalidTokenError.errorCode]: InvalidTokenError, + [MethodNotAllowedError.errorCode]: MethodNotAllowedError, + [TooManyRequestsError.errorCode]: TooManyRequestsError, + [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, + [InsufficientScopeError.errorCode]: InsufficientScopeError, + [InvalidTargetError.errorCode]: InvalidTargetError +} as const; diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts new file mode 100644 index 000000000..dcb6c03ec --- /dev/null +++ b/packages/server/src/server/auth/handlers/authorize.ts @@ -0,0 +1,165 @@ +import { RequestHandler } from 'express'; +import * as z from 'zod/v4'; +import express from 'express'; +import { OAuthServerProvider } from '../provider.js'; +import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; + +export type AuthorizationHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the authorization endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +// Parameters that must be validated in order to issue redirects. +const ClientAuthorizationParamsSchema = z.object({ + client_id: z.string(), + redirect_uri: z + .string() + .optional() + .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' }) +}); + +// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. +const RequestAuthorizationParamsSchema = z.object({ + response_type: z.literal('code'), + code_challenge: z.string(), + code_challenge_method: z.literal('S256'), + scope: z.string().optional(), + state: z.string().optional(), + resource: z.string().url().optional() +}); + +export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { + // Create a router to apply middleware + const router = express.Router(); + router.use(allowedMethods(['GET', 'POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + router.all('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + // In the authorization flow, errors are split into two categories: + // 1. Pre-redirect errors (direct response with 400) + // 2. Post-redirect errors (redirect with error parameters) + + // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. + let client_id, redirect_uri, client; + try { + const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); + if (!result.success) { + throw new InvalidRequestError(result.error.message); + } + + client_id = result.data.client_id; + redirect_uri = result.data.redirect_uri; + + client = await provider.clientsStore.getClient(client_id); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + + if (redirect_uri !== undefined) { + if (!client.redirect_uris.includes(redirect_uri)) { + throw new InvalidRequestError('Unregistered redirect_uri'); + } + } else if (client.redirect_uris.length === 1) { + redirect_uri = client.redirect_uris[0]; + } else { + throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs'); + } + } catch (error) { + // Pre-redirect errors - return direct response + // + // These don't need to be JSON encoded, as they'll be displayed in a user + // agent, but OTOH they all represent exceptional situations (arguably, + // "programmer error"), so presenting a nice HTML page doesn't help the + // user anyway. + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + + return; + } + + // Phase 2: Validate other parameters. Any errors here should go into redirect responses. + let state; + try { + // Parse and validate authorization parameters + const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { scope, code_challenge, resource } = parseResult.data; + state = parseResult.data.state; + + // Validate scopes + let requestedScopes: string[] = []; + if (scope !== undefined) { + requestedScopes = scope.split(' '); + } + + // All validation passed, proceed with authorization + await provider.authorize( + client, + { + state, + scopes: requestedScopes, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + resource: resource ? new URL(resource) : undefined + }, + res + ); + } catch (error) { + // Post-redirect errors - redirect with error parameters + if (error instanceof OAuthError) { + res.redirect(302, createErrorRedirect(redirect_uri, error, state)); + } else { + const serverError = new ServerError('Internal Server Error'); + res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); + } + } + }); + + return router; +} + +/** + * Helper function to create redirect URL with error parameters + */ +function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { + const errorUrl = new URL(redirectUri); + errorUrl.searchParams.set('error', error.errorCode); + errorUrl.searchParams.set('error_description', error.message); + if (error.errorUri) { + errorUrl.searchParams.set('error_uri', error.errorUri); + } + if (state) { + errorUrl.searchParams.set('state', state); + } + return errorUrl.href; +} diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts new file mode 100644 index 000000000..e0f07a99b --- /dev/null +++ b/packages/server/src/server/auth/handlers/metadata.ts @@ -0,0 +1,19 @@ +import express, { RequestHandler } from 'express'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../shared/auth.js'; +import cors from 'cors'; +import { allowedMethods } from '../middleware/allowedMethods.js'; + +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['GET', 'OPTIONS'])); + router.get('/', (req, res) => { + res.status(200).json(metadata); + }); + + return router; +} diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts new file mode 100644 index 000000000..1830619b4 --- /dev/null +++ b/packages/server/src/server/auth/handlers/register.ts @@ -0,0 +1,119 @@ +import express, { RequestHandler } from 'express'; +import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../shared/auth.js'; +import crypto from 'node:crypto'; +import cors from 'cors'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; + +export type ClientRegistrationHandlerOptions = { + /** + * A store used to save information about dynamically registered OAuth clients. + */ + clientsStore: OAuthRegisteredClientsStore; + + /** + * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). + * + * If not set, defaults to 30 days. + */ + clientSecretExpirySeconds?: number; + + /** + * Rate limiting configuration for the client registration endpoint. + * Set to false to disable rate limiting for this endpoint. + * Registration endpoints are particularly sensitive to abuse and should be rate limited. + */ + rateLimit?: Partial | false; + + /** + * Whether to generate a client ID before calling the client registration endpoint. + * + * If not set, defaults to true. + */ + clientIdGeneration?: boolean; +}; + +const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days + +export function clientRegistrationHandler({ + clientsStore, + clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, + rateLimit: rateLimitConfig, + clientIdGeneration = true +}: ClientRegistrationHandlerOptions): RequestHandler { + if (!clientsStore.registerClient) { + throw new Error('Client registration store does not support registering clients'); + } + + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.json()); + + // Apply rate limiting unless explicitly disabled - stricter limits for registration + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 20, // 20 requests per hour - stricter as registration is sensitive + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = OAuthClientMetadataSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidClientMetadataError(parseResult.error.message); + } + + const clientMetadata = parseResult.data; + const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none'; + + // Generate client credentials + const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); + const clientIdIssuedAt = Math.floor(Date.now() / 1000); + + // Calculate client secret expiry time + const clientsDoExpire = clientSecretExpirySeconds > 0; + const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0; + const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime; + + let clientInfo: Omit & { client_id?: string } = { + ...clientMetadata, + client_secret: clientSecret, + client_secret_expires_at: clientSecretExpiresAt + }; + + if (clientIdGeneration) { + clientInfo.client_id = crypto.randomUUID(); + clientInfo.client_id_issued_at = clientIdIssuedAt; + } + + clientInfo = await clientsStore.registerClient!(clientInfo); + res.status(201).json(clientInfo); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts new file mode 100644 index 000000000..da7ef04f8 --- /dev/null +++ b/packages/server/src/server/auth/handlers/revoke.ts @@ -0,0 +1,79 @@ +import { OAuthServerProvider } from '../provider.js'; +import express, { RequestHandler } from 'express'; +import cors from 'cors'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import { OAuthTokenRevocationRequestSchema } from '../../../shared/auth.js'; +import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; + +export type RevocationHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the token revocation endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { + if (!provider.revokeToken) { + throw new Error('Auth provider does not support revoking tokens'); + } + + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + // Authenticate and extract client details + router.use(authenticateClient({ clientsStore: provider.clientsStore })); + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const client = req.client; + if (!client) { + // This should never happen + throw new ServerError('Internal Server Error'); + } + + await provider.revokeToken!(client, parseResult.data); + res.status(200).json({}); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts new file mode 100644 index 000000000..4cc4e8ab8 --- /dev/null +++ b/packages/server/src/server/auth/handlers/token.ts @@ -0,0 +1,155 @@ +import * as z from 'zod/v4'; +import express, { RequestHandler } from 'express'; +import { OAuthServerProvider } from '../provider.js'; +import cors from 'cors'; +import { verifyChallenge } from 'pkce-challenge'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { + InvalidRequestError, + InvalidGrantError, + UnsupportedGrantTypeError, + ServerError, + TooManyRequestsError, + OAuthError +} from '../errors.js'; + +export type TokenHandlerOptions = { + provider: OAuthServerProvider; + /** + * Rate limiting configuration for the token endpoint. + * Set to false to disable rate limiting for this endpoint. + */ + rateLimit?: Partial | false; +}; + +const TokenRequestSchema = z.object({ + grant_type: z.string() +}); + +const AuthorizationCodeGrantSchema = z.object({ + code: z.string(), + code_verifier: z.string(), + redirect_uri: z.string().optional(), + resource: z.string().url().optional() +}); + +const RefreshTokenGrantSchema = z.object({ + refresh_token: z.string(), + scope: z.string().optional(), + resource: z.string().url().optional() +}); + +export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { + // Nested router so we can configure middleware and restrict HTTP method + const router = express.Router(); + + // Configure CORS to allow any origin, to make accessible to web-based MCP clients + router.use(cors()); + + router.use(allowedMethods(['POST'])); + router.use(express.urlencoded({ extended: false })); + + // Apply rate limiting unless explicitly disabled + if (rateLimitConfig !== false) { + router.use( + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // 50 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), + ...rateLimitConfig + }) + ); + } + + // Authenticate and extract client details + router.use(authenticateClient({ clientsStore: provider.clientsStore })); + + router.post('/', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + + try { + const parseResult = TokenRequestSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { grant_type } = parseResult.data; + + const client = req.client; + if (!client) { + // This should never happen + throw new ServerError('Internal Server Error'); + } + + switch (grant_type) { + case 'authorization_code': { + const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { code, code_verifier, redirect_uri, resource } = parseResult.data; + + const skipLocalPkceValidation = provider.skipLocalPkceValidation; + + // Perform local PKCE validation unless explicitly skipped + // (e.g. to validate code_verifier in upstream server) + if (!skipLocalPkceValidation) { + const codeChallenge = await provider.challengeForAuthorizationCode(client, code); + if (!(await verifyChallenge(code_verifier, codeChallenge))) { + throw new InvalidGrantError('code_verifier does not match the challenge'); + } + } + + // Passes the code_verifier to the provider if PKCE validation didn't occur locally + const tokens = await provider.exchangeAuthorizationCode( + client, + code, + skipLocalPkceValidation ? code_verifier : undefined, + redirect_uri, + resource ? new URL(resource) : undefined + ); + res.status(200).json(tokens); + break; + } + + case 'refresh_token': { + const parseResult = RefreshTokenGrantSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + + const { refresh_token, scope, resource } = parseResult.data; + + const scopes = scope?.split(' '); + const tokens = await provider.exchangeRefreshToken( + client, + refresh_token, + scopes, + resource ? new URL(resource) : undefined + ); + res.status(200).json(tokens); + break; + } + // Additional auth methods will not be added on the server side of the SDK. + case 'client_credentials': + default: + throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); + } + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }); + + return router; +} diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts new file mode 100644 index 000000000..74633aa57 --- /dev/null +++ b/packages/server/src/server/auth/middleware/allowedMethods.ts @@ -0,0 +1,20 @@ +import { RequestHandler } from 'express'; +import { MethodNotAllowedError } from '../errors.js'; + +/** + * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. + * + * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST']) + * @returns Express middleware that returns a 405 error if method not in allowed list + */ +export function allowedMethods(allowedMethods: string[]): RequestHandler { + return (req, res, next) => { + if (allowedMethods.includes(req.method)) { + next(); + return; + } + + const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`); + res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject()); + }; +} diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts new file mode 100644 index 000000000..dac653086 --- /dev/null +++ b/packages/server/src/server/auth/middleware/bearerAuth.ts @@ -0,0 +1,102 @@ +import { RequestHandler } from 'express'; +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js'; +import { OAuthTokenVerifier } from '../provider.js'; +import { AuthInfo } from '../types.js'; + +export type BearerAuthMiddlewareOptions = { + /** + * A provider used to verify tokens. + */ + verifier: OAuthTokenVerifier; + + /** + * Optional scopes that the token must have. + */ + requiredScopes?: string[]; + + /** + * Optional resource metadata URL to include in WWW-Authenticate header. + */ + resourceMetadataUrl?: string; +}; + +declare module 'express-serve-static-core' { + interface Request { + /** + * Information about the validated access token, if the `requireBearerAuth` middleware was used. + */ + auth?: AuthInfo; + } +} + +/** + * Middleware that requires a valid Bearer token in the Authorization header. + * + * This will validate the token with the auth provider and add the resulting auth info to the request object. + * + * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header + * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. + */ +export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + throw new InvalidTokenError('Missing Authorization header'); + } + + const [type, token] = authHeader.split(' '); + if (type.toLowerCase() !== 'bearer' || !token) { + throw new InvalidTokenError("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 InsufficientScopeError('Insufficient scope'); + } + } + + // Check if the token is set to expire or if it is expired + if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { + throw new InvalidTokenError('Token has no expiration time'); + } else if (authInfo.expiresAt < Date.now() / 1000) { + throw new InvalidTokenError('Token has expired'); + } + + req.auth = authInfo; + next(); + } catch (error) { + // Build WWW-Authenticate header parts + const buildWwwAuthHeader = (errorCode: string, message: string): string => { + let header = `Bearer error="${errorCode}", error_description="${message}"`; + if (requiredScopes.length > 0) { + header += `, scope="${requiredScopes.join(' ')}"`; + } + if (resourceMetadataUrl) { + header += `, resource_metadata="${resourceMetadataUrl}"`; + } + return header; + }; + + if (error instanceof InvalidTokenError) { + res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); + res.status(401).json(error.toResponseObject()); + } else if (error instanceof InsufficientScopeError) { + res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); + res.status(403).json(error.toResponseObject()); + } else if (error instanceof ServerError) { + res.status(500).json(error.toResponseObject()); + } else if (error instanceof OAuthError) { + res.status(400).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts new file mode 100644 index 000000000..6cc6a1923 --- /dev/null +++ b/packages/server/src/server/auth/middleware/clientAuth.ts @@ -0,0 +1,64 @@ +import * as z from 'zod/v4'; +import { RequestHandler } from 'express'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull } from '../../../shared/auth.js'; +import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; + +export type ClientAuthenticationMiddlewareOptions = { + /** + * A store used to read information about registered OAuth clients. + */ + clientsStore: OAuthRegisteredClientsStore; +}; + +const ClientAuthenticatedRequestSchema = z.object({ + client_id: z.string(), + client_secret: z.string().optional() +}); + +declare module 'express-serve-static-core' { + interface Request { + /** + * The authenticated client for this request, if the `authenticateClient` middleware was used. + */ + client?: OAuthClientInformationFull; + } +} + +export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const result = ClientAuthenticatedRequestSchema.safeParse(req.body); + if (!result.success) { + throw new InvalidRequestError(String(result.error)); + } + const { client_id, client_secret } = result.data; + const client = await clientsStore.getClient(client_id); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + if (client.client_secret) { + if (!client_secret) { + throw new InvalidClientError('Client secret is required'); + } + if (client.client_secret !== client_secret) { + throw new InvalidClientError('Invalid client_secret'); + } + if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { + throw new InvalidClientError('Client secret has expired'); + } + } + + req.client = client; + next(); + } catch (error) { + if (error instanceof OAuthError) { + const status = error instanceof ServerError ? 500 : 400; + res.status(status).json(error.toResponseObject()); + } else { + const serverError = new ServerError('Internal Server Error'); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts new file mode 100644 index 000000000..cf1c306de --- /dev/null +++ b/packages/server/src/server/auth/provider.ts @@ -0,0 +1,83 @@ +import { Response } from 'express'; +import { OAuthRegisteredClientsStore } from './clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; +import { AuthInfo } from './types.js'; + +export type AuthorizationParams = { + state?: string; + scopes?: string[]; + codeChallenge: string; + redirectUri: string; + resource?: URL; +}; + +/** + * Implements an end-to-end OAuth server. + */ +export interface OAuthServerProvider { + /** + * A store used to read information about registered OAuth clients. + */ + get clientsStore(): OAuthRegisteredClientsStore; + + /** + * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. + * + * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: + * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. + * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. + */ + authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; + + /** + * Returns the `codeChallenge` that was used when the indicated authorization began. + */ + challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; + + /** + * Exchanges an authorization code for an access token. + */ + exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise; + + /** + * Exchanges a refresh token for an access token. + */ + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; + + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; + + /** + * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). + * + * If the given token is invalid or already revoked, this method should do nothing. + */ + revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; + + /** + * Whether to skip local PKCE validation. + * + * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. + * + * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. + */ + skipLocalPkceValidation?: boolean; +} + +/** + * Slim implementation useful for token verification + */ +export interface OAuthTokenVerifier { + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; +} diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts new file mode 100644 index 000000000..855856c89 --- /dev/null +++ b/packages/server/src/server/auth/providers/proxyProvider.ts @@ -0,0 +1,238 @@ +import { Response } from 'express'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { + OAuthClientInformationFull, + OAuthClientInformationFullSchema, + OAuthTokenRevocationRequest, + OAuthTokens, + OAuthTokensSchema +} from '../../../shared/auth.js'; +import { AuthInfo } from '../types.js'; +import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; +import { ServerError } from '../errors.js'; +import { FetchLike } from '../../../shared/transport.js'; + +export type ProxyEndpoints = { + authorizationUrl: string; + tokenUrl: string; + revocationUrl?: string; + registrationUrl?: string; +}; + +export type ProxyOptions = { + /** + * Individual endpoint URLs for proxying specific OAuth operations + */ + endpoints: ProxyEndpoints; + + /** + * Function to verify access tokens and return auth info + */ + verifyAccessToken: (token: string) => Promise; + + /** + * Function to fetch client information from the upstream server + */ + getClient: (clientId: string) => Promise; + + /** + * Custom fetch implementation used for all network requests. + */ + fetch?: FetchLike; +}; + +/** + * Implements an OAuth server that proxies requests to another OAuth server. + */ +export class ProxyOAuthServerProvider implements OAuthServerProvider { + protected readonly _endpoints: ProxyEndpoints; + protected readonly _verifyAccessToken: (token: string) => Promise; + protected readonly _getClient: (clientId: string) => Promise; + protected readonly _fetch?: FetchLike; + + skipLocalPkceValidation = true; + + revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; + + constructor(options: ProxyOptions) { + this._endpoints = options.endpoints; + this._verifyAccessToken = options.verifyAccessToken; + this._getClient = options.getClient; + this._fetch = options.fetch; + if (options.endpoints?.revocationUrl) { + this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => { + const revocationUrl = this._endpoints.revocationUrl; + + if (!revocationUrl) { + throw new Error('No revocation endpoint configured'); + } + + const params = new URLSearchParams(); + params.set('token', request.token); + params.set('client_id', client.client_id); + if (client.client_secret) { + params.set('client_secret', client.client_secret); + } + if (request.token_type_hint) { + params.set('token_type_hint', request.token_type_hint); + } + + const response = await (this._fetch ?? fetch)(revocationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + await response.body?.cancel(); + + if (!response.ok) { + throw new ServerError(`Token revocation failed: ${response.status}`); + } + }; + } + } + + get clientsStore(): OAuthRegisteredClientsStore { + const registrationUrl = this._endpoints.registrationUrl; + return { + getClient: this._getClient, + ...(registrationUrl && { + registerClient: async (client: OAuthClientInformationFull) => { + const response = await (this._fetch ?? fetch)(registrationUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(client) + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Client registration failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthClientInformationFullSchema.parse(data); + } + }) + }; + } + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Start with required OAuth parameters + const targetUrl = new URL(this._endpoints.authorizationUrl); + const searchParams = new URLSearchParams({ + client_id: client.client_id, + response_type: 'code', + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + code_challenge_method: 'S256' + }); + + // Add optional standard OAuth parameters + if (params.state) searchParams.set('state', params.state); + if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' ')); + if (params.resource) searchParams.set('resource', params.resource.href); + + targetUrl.search = searchParams.toString(); + res.redirect(targetUrl.toString()); + } + + async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise { + // In a proxy setup, we don't store the code challenge ourselves + // Instead, we proxy the token request and let the upstream server validate it + return ''; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: client.client_id, + code: authorizationCode + }); + + if (client.client_secret) { + params.append('client_secret', client.client_secret); + } + + if (codeVerifier) { + params.append('code_verifier', codeVerifier); + } + + if (redirectUri) { + params.append('redirect_uri', redirectUri); + } + + if (resource) { + params.append('resource', resource.href); + } + + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Token exchange failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: client.client_id, + refresh_token: refreshToken + }); + + if (client.client_secret) { + params.set('client_secret', client.client_secret); + } + + if (scopes?.length) { + params.set('scope', scopes.join(' ')); + } + + if (resource) { + params.set('resource', resource.href); + } + + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + await response.body?.cancel(); + throw new ServerError(`Token refresh failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + async verifyAccessToken(token: string): Promise { + return this._verifyAccessToken(token); + } +} diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts new file mode 100644 index 000000000..1df0be091 --- /dev/null +++ b/packages/server/src/server/auth/router.ts @@ -0,0 +1,240 @@ +import express, { RequestHandler } from 'express'; +import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './handlers/register.js'; +import { tokenHandler, TokenHandlerOptions } from './handlers/token.js'; +import { authorizationHandler, AuthorizationHandlerOptions } from './handlers/authorize.js'; +import { revocationHandler, RevocationHandlerOptions } from './handlers/revoke.js'; +import { metadataHandler } from './handlers/metadata.js'; +import { OAuthServerProvider } from './provider.js'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../shared/auth.js'; + +// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) +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.'); +} + +export type AuthRouterOptions = { + /** + * A provider implementing the actual authorization logic for this router. + */ + provider: OAuthServerProvider; + + /** + * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. + */ + issuerUrl: URL; + + /** + * The base URL of the authorization server to use for the metadata endpoints. + * + * If not provided, the issuer URL will be used as the base URL. + */ + baseUrl?: URL; + + /** + * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this authorization server + */ + scopesSupported?: string[]; + + /** + * The resource name to be displayed in protected resource metadata + */ + resourceName?: string; + + /** + * The URL of the protected resource (RS) whose metadata we advertise. + * If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS). + */ + resourceServerUrl?: URL; + + // Individual options per route + authorizationOptions?: Omit; + clientRegistrationOptions?: Omit; + revocationOptions?: Omit; + tokenOptions?: Omit; +}; + +const checkIssuerUrl = (issuer: URL): void => { + // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of 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}`); + } +}; + +export const createOAuthMetadata = (options: { + provider: OAuthServerProvider; + issuerUrl: URL; + baseUrl?: URL; + serviceDocumentationUrl?: URL; + scopesSupported?: string[]; +}): OAuthMetadata => { + const issuer = options.issuerUrl; + const baseUrl = options.baseUrl; + + checkIssuerUrl(issuer); + + const authorization_endpoint = '/authorize'; + const token_endpoint = '/token'; + const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined; + const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined; + + const metadata: OAuthMetadata = { + issuer: issuer.href, + service_documentation: options.serviceDocumentationUrl?.href, + + authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + + token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + grant_types_supported: ['authorization_code', 'refresh_token'], + + scopes_supported: options.scopesSupported, + + revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, + revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined, + + registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined + }; + + return metadata; +}; + +/** + * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). + * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. + * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. + * + * By default, rate limiting is applied to all endpoints to prevent abuse. + * + * This router MUST be installed at the application root, like so: + * + * const app = express(); + * app.use(mcpAuthRouter(...)); + */ +export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { + const oauthMetadata = createOAuthMetadata(options); + + const router = express.Router(); + + router.use( + new URL(oauthMetadata.authorization_endpoint).pathname, + authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) + ); + + router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions })); + + router.use( + mcpAuthMetadataRouter({ + oauthMetadata, + // Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat) + resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer), + serviceDocumentationUrl: options.serviceDocumentationUrl, + scopesSupported: options.scopesSupported, + resourceName: options.resourceName + }) + ); + + if (oauthMetadata.registration_endpoint) { + router.use( + new URL(oauthMetadata.registration_endpoint).pathname, + clientRegistrationHandler({ + clientsStore: options.provider.clientsStore, + ...options.clientRegistrationOptions + }) + ); + } + + if (oauthMetadata.revocation_endpoint) { + router.use( + new URL(oauthMetadata.revocation_endpoint).pathname, + revocationHandler({ provider: options.provider, ...options.revocationOptions }) + ); + } + + return router; +} + +export type AuthMetadataOptions = { + /** + * OAuth Metadata as would be returned from the authorization server + * this MCP server relies on + */ + oauthMetadata: OAuthMetadata; + + /** + * The url of the MCP server, for use in protected resource metadata + */ + resourceServerUrl: URL; + + /** + * The url for documentation for the MCP server + */ + serviceDocumentationUrl?: URL; + + /** + * An optional list of scopes supported by this MCP server + */ + scopesSupported?: string[]; + + /** + * An optional resource name to display in resource metadata + */ + resourceName?: string; +}; + +export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.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-specific URL per RFC 9728 + const rsPath = new URL(options.resourceServerUrl.href).pathname; + router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); + + // Always add this for OAuth Authorization Server metadata per RFC 8414 + router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata)); + + return router; +} + +/** + * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL + * from a given server URL. This replaces the path with the standard metadata endpoint. + * + * @param serverUrl - The base URL of the protected resource server + * @returns The URL for the OAuth protected resource metadata endpoint + * + * @example + * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) + * // Returns: '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/server/src/server/completable.ts b/packages/server/src/server/completable.ts new file mode 100644 index 000000000..be067ac55 --- /dev/null +++ b/packages/server/src/server/completable.ts @@ -0,0 +1,67 @@ +import { AnySchema, SchemaInput } from './zod-compat.js'; + +export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); + +export type CompleteCallback = ( + value: SchemaInput, + context?: { + arguments?: Record; + } +) => SchemaInput[] | Promise[]>; + +export type CompletableMeta = { + complete: CompleteCallback; +}; + +export type CompletableSchema = T & { + [COMPLETABLE_SYMBOL]: CompletableMeta; +}; + +/** + * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. + * Works with both Zod v3 and v4 schemas. + */ +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; +} + +/** + * Unwraps a completable schema to get the underlying schema. + * For backward compatibility with code that called `.unwrap()`. + */ +export function unwrapCompletable(schema: CompletableSchema): T { + return schema; +} + +// Legacy exports for backward compatibility +// These types are deprecated but kept for existing code +export enum McpZodTypeKind { + Completable = 'McpCompletable' +} + +export interface CompletableDef { + type: T; + complete: CompleteCallback; + typeName: McpZodTypeKind.Completable; +} diff --git a/packages/server/src/server/express.ts b/packages/server/src/server/express.ts new file mode 100644 index 000000000..a542acd7a --- /dev/null +++ b/packages/server/src/server/express.ts @@ -0,0 +1,74 @@ +import express, { 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[]; +} + +/** + * 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 + * ```typescript + * // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * const app = createMcpExpressApp(); + * + * // Custom host - DNS rebinding protection only applied for localhost hosts + * const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * + * // Custom allowed hosts for non-localhost binding + * 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 } = options; + + const app = express(); + app.use(express.json()); + + // 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/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts new file mode 100644 index 000000000..1744cc76b --- /dev/null +++ b/packages/server/src/server/mcp.ts @@ -0,0 +1,1542 @@ +import { Server, ServerOptions } from './server.js'; +import { + AnySchema, + AnyObjectSchema, + ZodRawShapeCompat, + SchemaOutput, + ShapeOutput, + normalizeObjectSchema, + safeParseAsync, + getObjectShape, + objectFromShape, + getParseErrorMessage, + getSchemaDescription, + isSchemaOptional, + getLiteralValue, + toJsonSchemaCompat +} from '@modelcontextprotocol/shared'; +import { + Implementation, + Tool, + ListToolsResult, + CallToolResult, + McpError, + ErrorCode, + CompleteResult, + PromptReference, + ResourceTemplateReference, + BaseMetadata, + Resource, + ListResourcesResult, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + ListToolsRequestSchema, + CallToolRequestSchema, + ListResourcesRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, + CompleteRequestSchema, + ListPromptsResult, + Prompt, + PromptArgument, + GetPromptResult, + ReadResourceResult, + ServerRequest, + ServerNotification, + ToolAnnotations, + LoggingMessageNotification, + CreateTaskResult, + Result, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + CallToolRequest, + ToolExecution +} from '@modelcontextprotocol/shared'; +import { isCompletable, getCompleter } from './completable.js'; +import { UriTemplate, Variables } from '@modelcontextprotocol/shared'; +import { RequestHandlerExtra } from '@modelcontextprotocol/shared'; +import { Transport } from '@modelcontextprotocol/shared'; + +import { validateAndWarnToolName } from '@modelcontextprotocol/shared'; +import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js'; +import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; +import { ZodOptional } from 'zod'; + +/** + * 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 + * Server instance available via the `server` property. + */ +export class McpServer { + /** + * The underlying 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 Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. + */ + 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(getMethodValue(ListToolsRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(CallToolRequestSchema)); + + this.server.registerCapabilities({ + tools: { + listChanged: true + } + }); + + this.server.setRequestHandler( + ListToolsRequestSchema, + (): 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: (() => { + const obj = normalizeObjectSchema(tool.inputSchema); + return obj + ? (toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: 'input' + }) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA; + })(), + annotations: tool.annotations, + execution: tool.execution, + _meta: tool._meta + }; + + if (tool.outputSchema) { + const obj = normalizeObjectSchema(tool.outputSchema); + if (obj) { + toolDefinition.outputSchema = toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: 'output' + }) as Tool['outputSchema']; + } + } + + return toolDefinition; + }) + }) + ); + + this.server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { + try { + const tool = this._registeredTools[request.params.name]; + if (!tool) { + throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); + } + if (!tool.enabled) { + throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); + } + + 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 McpError( + ErrorCode.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 McpError( + ErrorCode.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, extra); + } + + // Normal execution path + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const result = await this.executeToolHandler(tool, args, extra); + + // 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 McpError) { + if (error.code === ErrorCode.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< + Tool extends RegisteredTool, + Args extends Tool['inputSchema'] extends infer InputSchema + ? InputSchema extends AnySchema + ? SchemaOutput + : undefined + : undefined + >(tool: Tool, args: Args, toolName: string): Promise { + if (!tool.inputSchema) { + return undefined as Args; + } + + // Try to normalize to object schema first (for raw shapes and object schemas) + // If that fails, use the schema directly (for union/intersection/etc) + const inputObj = normalizeObjectSchema(tool.inputSchema); + const schemaToParse = inputObj ?? (tool.inputSchema as AnySchema); + const parseResult = await safeParseAsync(schemaToParse, args); + if (!parseResult.success) { + const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; + const errorMessage = getParseErrorMessage(error); + throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`); + } + + 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 McpError( + ErrorCode.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 outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema; + const parseResult = await safeParseAsync(outputObj, result.structuredContent); + if (!parseResult.success) { + const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; + const errorMessage = getParseErrorMessage(error); + throw new McpError( + ErrorCode.InvalidParams, + `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}` + ); + } + } + + /** + * Executes a tool handler (either regular or task-based). + */ + private async executeToolHandler( + tool: RegisteredTool, + args: unknown, + extra: RequestHandlerExtra + ): Promise { + const handler = tool.handler as AnyToolHandler; + const isTaskHandler = 'createTask' in handler; + + if (isTaskHandler) { + if (!extra.taskStore) { + throw new Error('No task store provided.'); + } + const taskExtra = { ...extra, taskStore: extra.taskStore }; + + if (tool.inputSchema) { + const typedHandler = handler as ToolTaskHandler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await Promise.resolve(typedHandler.createTask(args as any, taskExtra)); + } else { + const typedHandler = handler as ToolTaskHandler; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await Promise.resolve((typedHandler.createTask as any)(taskExtra)); + } + } + + if (tool.inputSchema) { + const typedHandler = handler as ToolCallback; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await Promise.resolve(typedHandler(args as any, extra)); + } else { + const typedHandler = handler as ToolCallback; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await Promise.resolve((typedHandler as any)(extra)); + } + } + + /** + * Handles automatic task polling for tools with taskSupport 'optional'. + */ + private async handleAutomaticTaskPolling( + tool: RegisteredTool, + request: RequestT, + extra: RequestHandlerExtra + ): Promise { + if (!extra.taskStore) { + throw new Error('No task store provided for task-capable tool.'); + } + + // Validate input and create task + const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + const handler = tool.handler as ToolTaskHandler; + const taskExtra = { ...extra, taskStore: extra.taskStore }; + + const createTaskResult: CreateTaskResult = args // undefined only if tool.inputSchema is undefined + ? await Promise.resolve((handler as ToolTaskHandler).createTask(args, taskExtra)) + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Promise.resolve(((handler as ToolTaskHandler).createTask as any)(taskExtra)); + + // 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 extra.taskStore.getTask(taskId); + if (!updatedTask) { + throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`); + } + task = updatedTask; + } + + // Return the final result + return (await extra.taskStore.getTaskResult(taskId)) as CallToolResult; + } + + private _completionHandlerInitialized = false; + + private setCompletionRequestHandler() { + if (this._completionHandlerInitialized) { + return; + } + + this.server.assertCanSetRequestHandler(getMethodValue(CompleteRequestSchema)); + + this.server.registerCapabilities({ + completions: {} + }); + + this.server.setRequestHandler(CompleteRequestSchema, 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 McpError(ErrorCode.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 McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} not found`); + } + + if (!prompt.enabled) { + throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); + } + + if (!prompt.argsSchema) { + return EMPTY_COMPLETION_RESULT; + } + + const promptShape = getObjectShape(prompt.argsSchema); + const field = 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 McpError(ErrorCode.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(getMethodValue(ListResourcesRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(ListResourceTemplatesRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(ReadResourceRequestSchema)); + + this.server.registerCapabilities({ + resources: { + listChanged: true + } + }); + + this.server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { + 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(extra); + 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(ListResourceTemplatesRequestSchema, async () => { + const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ + name, + uriTemplate: template.resourceTemplate.uriTemplate.toString(), + ...template.metadata + })); + + return { resourceTemplates }; + }); + + this.server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => { + 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 McpError(ErrorCode.InvalidParams, `Resource ${uri} disabled`); + } + return resource.readCallback(uri, extra); + } + + // 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, extra); + } + } + + throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`); + }); + + this._resourceHandlersInitialized = true; + } + + private _promptHandlersInitialized = false; + + private setPromptRequestHandlers() { + if (this._promptHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler(getMethodValue(ListPromptsRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(GetPromptRequestSchema)); + + this.server.registerCapabilities({ + prompts: { + listChanged: true + } + }); + + this.server.setRequestHandler( + ListPromptsRequestSchema, + (): 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 ? promptArgumentsFromSchema(prompt.argsSchema) : undefined + }; + }) + }) + ); + + this.server.setRequestHandler(GetPromptRequestSchema, async (request, extra): Promise => { + const prompt = this._registeredPrompts[request.params.name]; + if (!prompt) { + throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); + } + + if (!prompt.enabled) { + throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); + } + + if (prompt.argsSchema) { + const argsObj = normalizeObjectSchema(prompt.argsSchema) as AnyObjectSchema; + const parseResult = await safeParseAsync(argsObj, request.params.arguments); + if (!parseResult.success) { + const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; + const errorMessage = getParseErrorMessage(error); + throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage}`); + } + + const args = parseResult.data; + const cb = prompt.callback as PromptCallback; + return await Promise.resolve(cb(args, extra)); + } else { + const cb = prompt.callback as PromptCallback; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await Promise.resolve((cb as any)(extra)); + } + }); + + this._promptHandlersInitialized = true; + } + + /** + * Registers a resource `name` at a fixed URI, which will use the given callback to respond to read requests. + * @deprecated Use `registerResource` instead. + */ + resource(name: string, uri: string, readCallback: ReadResourceCallback): RegisteredResource; + + /** + * Registers a resource `name` at a fixed URI with metadata, which will use the given callback to respond to read requests. + * @deprecated Use `registerResource` instead. + */ + resource(name: string, uri: string, metadata: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; + + /** + * Registers a resource `name` with a template pattern, which will use the given callback to respond to read requests. + * @deprecated Use `registerResource` instead. + */ + resource(name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; + + /** + * Registers a resource `name` with a template pattern and metadata, which will use the given callback to respond to read requests. + * @deprecated Use `registerResource` instead. + */ + resource( + name: string, + template: ResourceTemplate, + metadata: ResourceMetadata, + readCallback: ReadResourceTemplateCallback + ): RegisteredResourceTemplate; + + resource(name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[]): RegisteredResource | RegisteredResourceTemplate { + let metadata: ResourceMetadata | undefined; + if (typeof rest[0] === 'object') { + metadata = rest.shift() as ResourceMetadata; + } + + const readCallback = rest[0] as ReadResourceCallback | ReadResourceTemplateCallback; + + if (typeof uriOrTemplate === 'string') { + if (this._registeredResources[uriOrTemplate]) { + throw new Error(`Resource ${uriOrTemplate} is already registered`); + } + + const registeredResource = this._createRegisteredResource( + name, + undefined, + uriOrTemplate, + metadata, + 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, + undefined, + uriOrTemplate, + metadata, + readCallback as ReadResourceTemplateCallback + ); + + this.setResourceRequestHandlers(); + this.sendResourceListChanged(); + return registeredResourceTemplate; + } + } + + /** + * Registers a resource with a config object and callback. + * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. + */ + 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 (typeof updates.uri !== 'undefined' && updates.uri !== uri) { + delete this._registeredResources[uri]; + if (updates.uri) this._registeredResources[updates.uri] = registeredResource; + } + if (typeof updates.name !== 'undefined') registeredResource.name = updates.name; + if (typeof updates.title !== 'undefined') registeredResource.title = updates.title; + if (typeof updates.metadata !== 'undefined') registeredResource.metadata = updates.metadata; + if (typeof updates.callback !== 'undefined') registeredResource.readCallback = updates.callback; + if (typeof 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 (typeof updates.name !== 'undefined' && updates.name !== name) { + delete this._registeredResourceTemplates[name]; + if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; + } + if (typeof updates.title !== 'undefined') registeredResourceTemplate.title = updates.title; + if (typeof updates.template !== 'undefined') registeredResourceTemplate.resourceTemplate = updates.template; + if (typeof updates.metadata !== 'undefined') registeredResourceTemplate.metadata = updates.metadata; + if (typeof updates.callback !== 'undefined') registeredResourceTemplate.readCallback = updates.callback; + if (typeof 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: PromptArgsRawShape | undefined, + callback: PromptCallback + ): RegisteredPrompt { + const registeredPrompt: RegisteredPrompt = { + title, + description, + argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), + callback, + enabled: true, + disable: () => registeredPrompt.update({ enabled: false }), + enable: () => registeredPrompt.update({ enabled: true }), + remove: () => registeredPrompt.update({ name: null }), + update: updates => { + if (typeof updates.name !== 'undefined' && updates.name !== name) { + delete this._registeredPrompts[name]; + if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; + } + if (typeof updates.title !== 'undefined') registeredPrompt.title = updates.title; + if (typeof updates.description !== 'undefined') registeredPrompt.description = updates.description; + if (typeof updates.argsSchema !== 'undefined') registeredPrompt.argsSchema = objectFromShape(updates.argsSchema); + if (typeof updates.callback !== 'undefined') registeredPrompt.callback = updates.callback; + if (typeof 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 hasCompletable = Object.values(argsSchema).some(field => { + const inner: unknown = field instanceof ZodOptional ? field._def?.innerType : field; + return isCompletable(inner); + }); + if (hasCompletable) { + this.setCompletionRequestHandler(); + } + } + + return registeredPrompt; + } + + private _createRegisteredTool( + name: string, + title: string | undefined, + description: string | undefined, + inputSchema: ZodRawShapeCompat | AnySchema | undefined, + outputSchema: ZodRawShapeCompat | AnySchema | undefined, + annotations: ToolAnnotations | undefined, + execution: ToolExecution | undefined, + _meta: Record | undefined, + handler: AnyToolHandler + ): RegisteredTool { + // Validate tool name according to SEP specification + validateAndWarnToolName(name); + + const registeredTool: RegisteredTool = { + title, + description, + inputSchema: getZodSchemaObject(inputSchema), + outputSchema: getZodSchemaObject(outputSchema), + annotations, + execution, + _meta, + handler: handler, + enabled: true, + disable: () => registeredTool.update({ enabled: false }), + enable: () => registeredTool.update({ enabled: true }), + remove: () => registeredTool.update({ name: null }), + update: updates => { + if (typeof 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 (typeof updates.title !== 'undefined') registeredTool.title = updates.title; + if (typeof updates.description !== 'undefined') registeredTool.description = updates.description; + if (typeof updates.paramsSchema !== 'undefined') registeredTool.inputSchema = objectFromShape(updates.paramsSchema); + if (typeof updates.outputSchema !== 'undefined') registeredTool.outputSchema = objectFromShape(updates.outputSchema); + if (typeof updates.callback !== 'undefined') registeredTool.handler = updates.callback; + if (typeof updates.annotations !== 'undefined') registeredTool.annotations = updates.annotations; + if (typeof updates._meta !== 'undefined') registeredTool._meta = updates._meta; + if (typeof updates.enabled !== 'undefined') registeredTool.enabled = updates.enabled; + this.sendToolListChanged(); + } + }; + this._registeredTools[name] = registeredTool; + + this.setToolRequestHandlers(); + this.sendToolListChanged(); + + return registeredTool; + } + + /** + * Registers a zero-argument tool `name`, which will run the given function when the client calls it. + * @deprecated Use `registerTool` instead. + */ + tool(name: string, cb: ToolCallback): RegisteredTool; + + /** + * Registers a zero-argument tool `name` (with a description) which will run the given function when the client calls it. + * @deprecated Use `registerTool` instead. + */ + tool(name: string, description: string, cb: ToolCallback): RegisteredTool; + + /** + * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. + * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. + * + * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShapeCompat during overload resolution, as both are plain object types. + * @deprecated Use `registerTool` instead. + */ + tool( + name: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; + + /** + * Registers a tool `name` (with a description) taking either parameter schema or annotations. + * This unified overload handles both `tool(name, description, paramsSchema, cb)` and + * `tool(name, description, annotations, cb)` cases. + * + * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate + * between ToolAnnotations and ZodRawShapeCompat during overload resolution, as both are plain object types. + * @deprecated Use `registerTool` instead. + */ + tool( + name: string, + description: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; + + /** + * Registers a tool with both parameter schema and annotations. + * @deprecated Use `registerTool` instead. + */ + tool( + name: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; + + /** + * Registers a tool with description, parameter schema, and annotations. + * @deprecated Use `registerTool` instead. + */ + tool( + name: string, + description: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; + + /** + * tool() implementation. Parses arguments passed to overrides defined above. + */ + tool(name: string, ...rest: unknown[]): RegisteredTool { + if (this._registeredTools[name]) { + throw new Error(`Tool ${name} is already registered`); + } + + let description: string | undefined; + let inputSchema: ZodRawShapeCompat | undefined; + let outputSchema: ZodRawShapeCompat | undefined; + let annotations: ToolAnnotations | undefined; + + // Tool properties are passed as separate arguments, with omissions allowed. + // Support for this style is frozen as of protocol version 2025-03-26. Future additions + // to tool definition should *NOT* be added. + + if (typeof rest[0] === 'string') { + description = rest.shift() as string; + } + + // Handle the different overload combinations + if (rest.length > 1) { + // We have at least one more arg before the callback + const firstArg = rest[0]; + + if (isZodRawShapeCompat(firstArg)) { + // We have a params schema as the first arg + inputSchema = rest.shift() as ZodRawShapeCompat; + + // Check if the next arg is potentially annotations + if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { + // Case: tool(name, paramsSchema, annotations, cb) + // Or: tool(name, description, paramsSchema, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } + } else if (typeof firstArg === 'object' && firstArg !== null) { + // Not a ZodRawShapeCompat, so must be annotations in this position + // Case: tool(name, annotations, cb) + // Or: tool(name, description, annotations, cb) + annotations = rest.shift() as ToolAnnotations; + } + } + const callback = rest[0] as ToolCallback; + + return this._createRegisteredTool( + name, + undefined, + description, + inputSchema, + outputSchema, + annotations, + { taskSupport: 'forbidden' }, + undefined, + callback + ); + } + + /** + * Registers a tool with a config object and callback. + */ + registerTool( + name: string, + config: { + title?: string; + description?: string; + inputSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + }, + cb: ToolCallback + ): 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, + inputSchema, + outputSchema, + annotations, + { taskSupport: 'forbidden' }, + _meta, + cb as ToolCallback + ); + } + + /** + * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. + * @deprecated Use `registerPrompt` instead. + */ + prompt(name: string, cb: PromptCallback): RegisteredPrompt; + + /** + * Registers a zero-argument prompt `name` (with a description) which will run the given function when the client calls it. + * @deprecated Use `registerPrompt` instead. + */ + prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; + + /** + * Registers a prompt `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * @deprecated Use `registerPrompt` instead. + */ + prompt(name: string, argsSchema: Args, cb: PromptCallback): RegisteredPrompt; + + /** + * Registers a prompt `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. + * @deprecated Use `registerPrompt` instead. + */ + prompt( + name: string, + description: string, + argsSchema: Args, + cb: PromptCallback + ): RegisteredPrompt; + + prompt(name: string, ...rest: unknown[]): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + let description: string | undefined; + if (typeof rest[0] === 'string') { + description = rest.shift() as string; + } + + let argsSchema: PromptArgsRawShape | undefined; + if (rest.length > 1) { + argsSchema = rest.shift() as PromptArgsRawShape; + } + + const cb = rest[0] as PromptCallback; + const registeredPrompt = this._createRegisteredPrompt(name, undefined, description, argsSchema, cb); + + this.setPromptRequestHandlers(); + this.sendPromptListChanged(); + + return registeredPrompt; + } + + /** + * Registers a prompt with a config object and callback. + */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: Args; + }, + cb: PromptCallback + ): RegisteredPrompt { + if (this._registeredPrompts[name]) { + throw new Error(`Prompt ${name} is already registered`); + } + + const { title, description, argsSchema } = config; + + const registeredPrompt = this._createRegisteredPrompt( + name, + title, + description, + argsSchema, + cb as PromptCallback + ); + + 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 LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + 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 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]; + } +} + +export type BaseToolCallback< + SendResultT extends Result, + Extra extends RequestHandlerExtra, + Args extends undefined | ZodRawShapeCompat | AnySchema +> = Args extends ZodRawShapeCompat + ? (args: ShapeOutput, extra: Extra) => SendResultT | Promise + : Args extends AnySchema + ? (args: SchemaOutput, extra: Extra) => SendResultT | Promise + : (extra: Extra) => SendResultT | Promise; + +/** + * Callback for a tool handler registered with Server.tool(). + * + * Parameters will include tool arguments, if applicable, as well as other request handler context. + * + * The callback should return: + * - `structuredContent` if the tool has an outputSchema defined + * - `content` if the tool does not have an outputSchema + * - Both fields are optional but typically one should be provided + */ +export type ToolCallback = BaseToolCallback< + CallToolResult, + RequestHandlerExtra, + Args +>; + +/** + * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). + */ +export type AnyToolHandler = ToolCallback | ToolTaskHandler; + +export type RegisteredTool = { + title?: string; + description?: string; + inputSchema?: AnySchema; + outputSchema?: AnySchema; + annotations?: ToolAnnotations; + execution?: ToolExecution; + _meta?: Record; + handler: AnyToolHandler; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + paramsSchema?: InputArgs; + outputSchema?: OutputArgs; + annotations?: ToolAnnotations; + _meta?: Record; + callback?: ToolCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +const EMPTY_OBJECT_JSON_SCHEMA = { + type: 'object' as const, + properties: {} +}; + +/** + * Checks if a value looks like a Zod schema by checking for parse/safeParse methods. + */ +function isZodTypeLike(value: unknown): value is AnySchema { + return ( + value !== null && + typeof value === 'object' && + 'parse' in value && + typeof value.parse === 'function' && + 'safeParse' in value && + typeof value.safeParse === 'function' + ); +} + +/** + * Checks if an object is a Zod schema instance (v3 or v4). + * + * Zod schemas have internal markers: + * - v3: `_def` property + * - v4: `_zod` property + * + * This includes transformed schemas like z.preprocess(), z.transform(), z.pipe(). + */ +function isZodSchemaInstance(obj: object): boolean { + return '_def' in obj || '_zod' in obj || isZodTypeLike(obj); +} + +/** + * Checks if an object is a "raw shape" - a plain object where values are Zod schemas. + * + * Raw shapes are used as shorthand: `{ name: z.string() }` instead of `z.object({ name: z.string() })`. + * + * IMPORTANT: This must NOT match actual Zod schema instances (like z.preprocess, z.pipe), + * which have internal properties that could be mistaken for schema values. + */ +function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + // If it's already a Zod schema instance, it's NOT a raw shape + if (isZodSchemaInstance(obj)) { + return false; + } + + // Empty objects are valid raw shapes (tools with no parameters) + if (Object.keys(obj).length === 0) { + return true; + } + + // A raw shape has at least one property that is a Zod schema + return Object.values(obj).some(isZodTypeLike); +} + +/** + * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, + * otherwise returns the schema as is. + */ +function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): AnySchema | undefined { + if (!schema) { + return undefined; + } + + if (isZodRawShapeCompat(schema)) { + return objectFromShape(schema); + } + + return schema; +} + +/** + * Additional, optional information for annotating a resource. + */ +export type ResourceMetadata = Omit; + +/** + * Callback to list all resources matching a given template. + */ +export type ListResourcesCallback = ( + extra: RequestHandlerExtra +) => ListResourcesResult | Promise; + +/** + * Callback to read a resource at a given URI. + */ +export type ReadResourceCallback = ( + uri: URL, + extra: RequestHandlerExtra +) => 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, + extra: RequestHandlerExtra +) => 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; +}; + +type PromptArgsRawShape = ZodRawShapeCompat; + +export type PromptCallback = Args extends PromptArgsRawShape + ? (args: ShapeOutput, extra: RequestHandlerExtra) => GetPromptResult | Promise + : (extra: RequestHandlerExtra) => GetPromptResult | Promise; + +export type RegisteredPrompt = { + title?: string; + description?: string; + argsSchema?: AnyObjectSchema; + callback: PromptCallback; + enabled: boolean; + enable(): void; + disable(): void; + update(updates: { + name?: string | null; + title?: string; + description?: string; + argsSchema?: Args; + callback?: PromptCallback; + enabled?: boolean; + }): void; + remove(): void; +}; + +function promptArgumentsFromSchema(schema: AnyObjectSchema): PromptArgument[] { + const shape = getObjectShape(schema); + if (!shape) return []; + return Object.entries(shape).map(([name, field]): PromptArgument => { + // Get description - works for both v3 and v4 + const description = getSchemaDescription(field); + // Check if optional - works for both v3 and v4 + const isOptional = isSchemaOptional(field); + return { + name, + description, + required: !isOptional + }; + }); +} + +function getMethodValue(schema: AnyObjectSchema): string { + const shape = getObjectShape(schema); + const methodSchema = shape?.method as AnySchema | undefined; + if (!methodSchema) { + throw new Error('Schema is missing a method literal'); + } + + // Extract literal value - works for both v3 and v4 + const value = getLiteralValue(methodSchema); + if (typeof value === 'string') { + return value; + } + + throw new Error('Schema method literal must be a string'); +} + +function createCompletionResult(suggestions: string[]): CompleteResult { + return { + completion: { + values: suggestions.slice(0, 100), + total: suggestions.length, + hasMore: suggestions.length > 100 + } + }; +} + +const EMPTY_COMPLETION_RESULT: CompleteResult = { + completion: { + values: [], + hasMore: false + } +}; diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts new file mode 100644 index 000000000..165003635 --- /dev/null +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction, RequestHandler } 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 + * ```typescript + * 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 hostHeader = req.headers.host; + if (!hostHeader) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Missing Host header' + }, + id: null + }); + return; + } + + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host header: ${hostHeader}` + }, + id: null + }); + return; + } + + if (!allowedHostnames.includes(hostname)) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host: ${hostname}` + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. + * + * @example + * ```typescript + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation(): RequestHandler { + return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); +} diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts new file mode 100644 index 000000000..081ce525f --- /dev/null +++ b/packages/server/src/server/server.ts @@ -0,0 +1,669 @@ +import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/shared'; +import { + type ClientCapabilities, + type CreateMessageRequest, + type CreateMessageResult, + CreateMessageResultSchema, + type CreateMessageResultWithTools, + CreateMessageResultWithToolsSchema, + type CreateMessageRequestParamsBase, + type CreateMessageRequestParamsWithTools, + type ElicitRequestFormParams, + type ElicitRequestURLParams, + type ElicitResult, + ElicitResultSchema, + EmptyResultSchema, + ErrorCode, + type Implementation, + InitializedNotificationSchema, + type InitializeRequest, + InitializeRequestSchema, + type InitializeResult, + LATEST_PROTOCOL_VERSION, + type ListRootsRequest, + ListRootsResultSchema, + type LoggingLevel, + LoggingLevelSchema, + type LoggingMessageNotification, + McpError, + type ResourceUpdatedNotification, + type ServerCapabilities, + type ServerNotification, + type ServerRequest, + type ServerResult, + SetLevelRequestSchema, + SUPPORTED_PROTOCOL_VERSIONS, + type ToolResultContent, + type ToolUseContent, + CallToolRequestSchema, + CallToolResultSchema, + CreateTaskResultSchema, + type Request, + type Notification, + type Result +} from '@modelcontextprotocol/shared'; +import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; +import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; +import { + AnyObjectSchema, + getObjectShape, + isZ4Schema, + safeParse, + SchemaOutput, + type ZodV3Internal, + type ZodV4Internal +} from '@modelcontextprotocol/shared'; +import { RequestHandlerExtra } from '@modelcontextprotocol/shared'; +import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.js'; + +export type ServerOptions = ProtocolOptions & { + /** + * Capabilities to advertise as being supported by this server. + */ + capabilities?: ServerCapabilities; + + /** + * 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 AjvJsonSchemaValidator + * + * @example + * ```typescript + * // ajv (default) + * const server = new Server( + * { name: 'my-server', version: '1.0.0' }, + * { + * capabilities: {} + * jsonSchemaValidator: new AjvJsonSchemaValidator() + * } + * ); + * + * // @cfworker/json-schema + * const server = new Server( + * { name: 'my-server', version: '1.0.0' }, + * { + * capabilities: {}, + * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() + * } + * ); + * ``` + */ + 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. + * + * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: + * + * ```typescript + * // Custom schemas + * const CustomRequestSchema = RequestSchema.extend({...}) + * const CustomNotificationSchema = NotificationSchema.extend({...}) + * const CustomResultSchema = ResultSchema.extend({...}) + * + * // Type aliases + * type CustomRequest = z.infer + * type CustomNotification = z.infer + * type CustomResult = z.infer + * + * // Create typed server + * const server = new Server({ + * name: "CustomServer", + * version: "1.0.0" + * }) + * ``` + * @deprecated Use `McpServer` instead for the high-level API. Only use `Server` for advanced use cases. + */ +export class Server< + RequestT extends Request = Request, + NotificationT extends Notification = Notification, + ResultT extends Result = Result +> 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 `initialized` notification). + */ + oninitialized?: () => void; + + /** + * Initializes this server with the given name and version information. + */ + constructor( + private _serverInfo: Implementation, + options?: ServerOptions + ) { + super(options); + this._capabilities = options?.capabilities ?? {}; + this._instructions = options?.instructions; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); + + this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request)); + this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); + + if (this._capabilities.logging) { + this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { + const transportSessionId: string | undefined = + extra.sessionId || (extra.requestInfo?.headers['mcp-session-id'] as string) || undefined; + const { level } = request.params; + const parseResult = LoggingLevelSchema.safeParse(level); + if (parseResult.success) { + this._loggingLevels.set(transportSessionId, parseResult.data); + } + return {}; + }); + } + } + + /** + * 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 Error('Cannot register capabilities after connecting to transport'); + } + this._capabilities = mergeCapabilities(this._capabilities, capabilities); + } + + /** + * Override request handler registration to enforce server-side validation for tools/call. + */ + public override setRequestHandler( + requestSchema: T, + handler: ( + request: SchemaOutput, + extra: RequestHandlerExtra + ) => ServerResult | ResultT | Promise + ): void { + const shape = getObjectShape(requestSchema); + const methodSchema = shape?.method; + if (!methodSchema) { + throw new Error('Schema is missing a method literal'); + } + + // Extract literal value using type-safe property access + let methodValue: unknown; + if (isZ4Schema(methodSchema)) { + const v4Schema = methodSchema as unknown as ZodV4Internal; + const v4Def = v4Schema._zod?.def; + methodValue = v4Def?.value ?? v4Schema.value; + } else { + const v3Schema = methodSchema as unknown as ZodV3Internal; + const legacyDef = v3Schema._def; + methodValue = legacyDef?.value ?? v3Schema.value; + } + + if (typeof methodValue !== 'string') { + throw new Error('Schema method literal must be a string'); + } + const method = methodValue; + + if (method === 'tools/call') { + const wrappedHandler = async ( + request: SchemaOutput, + extra: RequestHandlerExtra + ): Promise => { + const validatedRequest = safeParse(CallToolRequestSchema, request); + if (!validatedRequest.success) { + const errorMessage = + validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); + } + + const { params } = validatedRequest.data; + + const result = await Promise.resolve(handler(request, extra)); + + // When task creation is requested, validate and return CreateTaskResult + if (params.task) { + const taskValidationResult = safeParse(CreateTaskResultSchema, result); + if (!taskValidationResult.success) { + const errorMessage = + taskValidationResult.error instanceof Error + ? taskValidationResult.error.message + : String(taskValidationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); + } + return taskValidationResult.data; + } + + // For non-task requests, validate against CallToolResultSchema + const validationResult = safeParse(CallToolResultSchema, result); + if (!validationResult.success) { + const errorMessage = + validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); + throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); + } + + return validationResult.data; + }; + + // Install the wrapped handler + return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); + } + + // Other handlers use default behavior + return super.setRequestHandler(requestSchema, handler); + } + + protected assertCapabilityForMethod(method: RequestT['method']): void { + switch (method as ServerRequest['method']) { + case 'sampling/createMessage': + if (!this._clientCapabilities?.sampling) { + throw new Error(`Client does not support sampling (required for ${method})`); + } + break; + + case 'elicitation/create': + if (!this._clientCapabilities?.elicitation) { + throw new Error(`Client does not support elicitation (required for ${method})`); + } + break; + + case 'roots/list': + if (!this._clientCapabilities?.roots) { + throw new Error(`Client does not support listing roots (required for ${method})`); + } + break; + + case 'ping': + // No specific capability required for ping + break; + } + } + + protected assertNotificationCapability(method: (ServerNotification | NotificationT)['method']): void { + switch (method as ServerNotification['method']) { + case 'notifications/message': + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'notifications/resources/updated': + case 'notifications/resources/list_changed': + if (!this._capabilities.resources) { + throw new Error(`Server does not support notifying about resources (required for ${method})`); + } + break; + + case 'notifications/tools/list_changed': + if (!this._capabilities.tools) { + throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); + } + break; + + case 'notifications/prompts/list_changed': + if (!this._capabilities.prompts) { + throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); + } + break; + + case 'notifications/elicitation/complete': + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error(`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 { + // Task handlers are registered in Protocol constructor before _capabilities is initialized + // Skip capability check for task methods during initialization + if (!this._capabilities) { + return; + } + + switch (method) { + case 'completion/complete': + if (!this._capabilities.completions) { + throw new Error(`Server does not support completions (required for ${method})`); + } + break; + + case 'logging/setLevel': + if (!this._capabilities.logging) { + throw new Error(`Server does not support logging (required for ${method})`); + } + break; + + case 'prompts/get': + case 'prompts/list': + if (!this._capabilities.prompts) { + throw new Error(`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 Error(`Server does not support resources (required for ${method})`); + } + break; + + case 'tools/call': + case 'tools/list': + if (!this._capabilities.tools) { + throw new Error(`Server does not support tools (required for ${method})`); + } + break; + + case 'tasks/get': + case 'tasks/list': + case 'tasks/result': + case 'tasks/cancel': + if (!this._capabilities.tasks) { + throw new Error(`Server does not support tasks capability (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 { + // Task handlers are registered in Protocol constructor before _capabilities is initialized + // Skip capability check for task methods during initialization + if (!this._capabilities) { + return; + } + + 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 = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; + + 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; + } + + private getCapabilities(): ServerCapabilities { + return this._capabilities; + } + + async ping() { + return this.request({ 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) { + if (!this._clientCapabilities?.sampling?.tools) { + throw new Error('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[params.messages.length - 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[params.messages.length - 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 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 Error('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.request({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + } + return this.request({ 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 Error('Client does not support url elicitation.'); + } + + const urlParams = params as ElicitRequestURLParams; + return this.request({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); + } + case 'form': { + if (!this._clientCapabilities?.elicitation?.form) { + throw new Error('Client does not support form elicitation.'); + } + + const formParams: ElicitRequestFormParams = + params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; + + const result = await this.request({ 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 McpError( + ErrorCode.InvalidParams, + `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` + ); + } + } catch (error) { + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.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 Error('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.request({ 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 LoggingMessageNotification + * @param params + * @param sessionId optional for stateless and backward compatibility + */ + async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { + if (this._capabilities.logging) { + if (!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/sse.ts b/packages/server/src/server/sse.ts new file mode 100644 index 000000000..b7450a09e --- /dev/null +++ b/packages/server/src/server/sse.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto'; +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Transport } from '../shared/transport.js'; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; +import getRawBody from 'raw-body'; +import contentType from 'content-type'; +import { AuthInfo } from './auth/types.js'; +import { URL } from 'node:url'; + +const MAXIMUM_MESSAGE_SIZE = '4mb'; + +/** + * Configuration options for SSEServerTransport. + */ +export interface SSEServerTransportOptions { + /** + * List of allowed host header values for DNS rebinding protection. + * If not specified, host validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + allowedOrigins?: string[]; + + /** + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + enableDnsRebindingProtection?: boolean; +} + +/** + * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. + * + * This transport is only available in Node.js environments. + * @deprecated SSEServerTransport is deprecated. Use StreamableHTTPServerTransport instead. + */ +export class SSEServerTransport implements Transport { + private _sseResponse?: ServerResponse; + private _sessionId: string; + private _options: SSEServerTransportOptions; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + + /** + * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. + */ + constructor( + private _endpoint: string, + private res: ServerResponse, + options?: SSEServerTransportOptions + ) { + this._sessionId = randomUUID(); + this._options = options || { enableDnsRebindingProtection: false }; + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | undefined { + // Skip validation if protection is not enabled + if (!this._options.enableDnsRebindingProtection) { + return undefined; + } + + // Validate Host header if allowedHosts is configured + if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { + const hostHeader = req.headers.host; + if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (originHeader && !this._options.allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; + } + + /** + * Handles the initial SSE connection request. + * + * This should be called when a GET request is made to establish the SSE stream. + */ + async start(): Promise { + if (this._sseResponse) { + throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); + } + + this.res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + + // Send the endpoint event + // Use a dummy base URL because this._endpoint is relative. + // This allows using URL/URLSearchParams for robust parameter handling. + const dummyBase = 'http://localhost'; // Any valid base works + const endpointUrl = new URL(this._endpoint, dummyBase); + endpointUrl.searchParams.set('sessionId', this._sessionId); + + // Reconstruct the relative URL string (pathname + search + hash) + const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; + + this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`); + + this._sseResponse = this.res; + this.res.on('close', () => { + this._sseResponse = undefined; + this.onclose?.(); + }); + } + + /** + * Handles incoming POST messages. + * + * This should be called when a POST request is made to send a message to the server. + */ + async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + if (!this._sseResponse) { + const message = 'SSE connection not established'; + res.writeHead(500).end(message); + throw new Error(message); + } + + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end(validationError); + this.onerror?.(new Error(validationError)); + return; + } + + const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; + + let body: string | unknown; + try { + const ct = contentType.parse(req.headers['content-type'] ?? ''); + if (ct.type !== 'application/json') { + throw new Error(`Unsupported content-type: ${ct.type}`); + } + + body = + parsedBody ?? + (await getRawBody(req, { + limit: MAXIMUM_MESSAGE_SIZE, + encoding: ct.parameters.charset ?? 'utf-8' + })); + } catch (error) { + res.writeHead(400).end(String(error)); + this.onerror?.(error as Error); + return; + } + + try { + await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); + } catch { + res.writeHead(400).end(`Invalid message: ${body}`); + return; + } + + res.writeHead(202).end('Accepted'); + } + + /** + * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. + */ + async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { + let parsedMessage: JSONRPCMessage; + try { + parsedMessage = JSONRPCMessageSchema.parse(message); + } catch (error) { + this.onerror?.(error as Error); + throw error; + } + + this.onmessage?.(parsedMessage, extra); + } + + async close(): Promise { + this._sseResponse?.end(); + this._sseResponse = undefined; + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise { + if (!this._sseResponse) { + throw new Error('Not connected'); + } + + this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); + } + + /** + * Returns the session ID for this transport. + * + * This can be used to route incoming POST requests. + */ + get sessionId(): string { + return this._sessionId; + } +} diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts new file mode 100644 index 000000000..e552af0fa --- /dev/null +++ b/packages/server/src/server/stdio.ts @@ -0,0 +1,92 @@ +import process from 'node:process'; +import { Readable, Writable } from 'node:stream'; +import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; +import { JSONRPCMessage } from '../types.js'; +import { Transport } from '../shared/transport.js'; + +/** + * 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. + */ +export class StdioServerTransport implements Transport { + private _readBuffer: ReadBuffer = new ReadBuffer(); + private _started = 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); + }; + + /** + * 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); + } + + 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 { + // Remove our event listeners first + this._stdin.off('data', this._ondata); + this._stdin.off('error', this._onerror); + + // 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 { + return new Promise(resolve => { + const json = serializeMessage(message); + if (this._stdout.write(json)) { + resolve(); + } else { + this._stdout.once('drain', resolve); + } + }); + } +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts new file mode 100644 index 000000000..ab1131f63 --- /dev/null +++ b/packages/server/src/server/streamableHttp.ts @@ -0,0 +1,969 @@ +import { IncomingMessage, ServerResponse } from 'node:http'; +import { Transport } from '../shared/transport.js'; +import { + MessageExtraInfo, + RequestInfo, + isInitializeRequest, + isJSONRPCRequest, + isJSONRPCResultResponse, + JSONRPCMessage, + JSONRPCMessageSchema, + RequestId, + SUPPORTED_PROTOCOL_VERSIONS, + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + isJSONRPCErrorResponse +} from '../types.js'; +import getRawBody from 'raw-body'; +import contentType from 'content-type'; +import { randomUUID } from 'node:crypto'; +import { AuthInfo } from './auth/types.js'; + +const MAXIMUM_MESSAGE_SIZE = '4mb'; + +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 + * replayEventsAfter for stream mapping. + */ + getStreamIdForEventId?(eventId: EventId): Promise; + + replayEventsAfter( + lastEventId: EventId, + { + send + }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + } + ): Promise; +} + +/** + * Configuration options for StreamableHTTPServerTransport + */ +export interface StreamableHTTPServerTransportOptions { + /** + * 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) + * + * Return undefined to disable session management. + */ + 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; + + /** + * 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 + * StreamableHTTPServerTransport 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; + + /** + * 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 the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + allowedHosts?: string[]; + + /** + * List of allowed origin header values for DNS rebinding protection. + * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + allowedOrigins?: string[]; + + /** + * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). + * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. + */ + 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; +} + +/** + * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. + * It supports both SSE streaming and direct HTTP responses. + * + * Usage example: + * + * ```typescript + * // Stateful mode - server sets the session ID + * const statefulTransport = new StreamableHTTPServerTransport({ + * sessionIdGenerator: () => randomUUID(), + * }); + * + * // Stateless mode - explicitly set session ID to undefined + * const statelessTransport = new StreamableHTTPServerTransport({ + * sessionIdGenerator: undefined, + * }); + * + * // Using with pre-parsed request body + * app.post('/mcp', (req, res) => { + * transport.handleRequest(req, res, req.body); + * }); + * ``` + * + * 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 + */ +export class StreamableHTTPServerTransport 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 _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; + private _onsessionclosed?: (sessionId: string) => void | Promise; + private _allowedHosts?: string[]; + private _allowedOrigins?: string[]; + private _enableDnsRebindingProtection: boolean; + private _retryInterval?: number; + + sessionId?: string; + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + + constructor(options: StreamableHTTPServerTransportOptions) { + 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; + } + + /** + * Starts the transport. This is required by the 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; + } + + /** + * Validates request headers for DNS rebinding protection. + * @returns Error message if validation fails, undefined if validation passes. + */ + private validateRequestHeaders(req: IncomingMessage): string | 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.host; + if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { + return `Invalid Host header: ${hostHeader}`; + } + } + + // Validate Origin header if allowedOrigins is configured + if (this._allowedOrigins && this._allowedOrigins.length > 0) { + const originHeader = req.headers.origin; + if (originHeader && !this._allowedOrigins.includes(originHeader)) { + return `Invalid Origin header: ${originHeader}`; + } + } + + return undefined; + } + + /** + * Handles an incoming HTTP request, whether GET or POST + */ + async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + // Validate request headers for DNS rebinding protection + const validationError = this.validateRequestHeaders(req); + if (validationError) { + res.writeHead(403).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: validationError + }, + id: null + }) + ); + this.onerror?.(new Error(validationError)); + return; + } + + if (req.method === 'POST') { + await this.handlePostRequest(req, res, parsedBody); + } else if (req.method === 'GET') { + await this.handleGetRequest(req, res); + } else if (req.method === 'DELETE') { + await this.handleDeleteRequest(req, res); + } else { + await this.handleUnsupportedRequest(res); + } + } + + /** + * 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 _maybeWritePrimingEvent(res: ServerResponse, 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`; + } + res.write(primingEvent); + } + + /** + * Handles GET requests for SSE stream + */ + private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { + // The client MUST include an Accept header, listing text/event-stream as a supported content type. + const acceptHeader = req.headers.accept; + if (!acceptHeader?.includes('text/event-stream')) { + res.writeHead(406).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Not Acceptable: Client must accept text/event-stream' + }, + id: null + }) + ); + return; + } + + // 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. + if (!this.validateSession(req, res)) { + return; + } + if (!this.validateProtocolVersion(req, res)) { + return; + } + // Handle resumability: check for Last-Event-ID header + if (this._eventStore) { + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + await this.replayEvents(lastEventId, res); + return; + } + } + + // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, + // or else return HTTP 405 Method Not Allowed + 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; + } + + // 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 + res.writeHead(409).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Conflict: Only one SSE stream is allowed per session' + }, + id: null + }) + ); + return; + } + + // We need to send headers immediately as messages will arrive much later, + // otherwise the client will just wait for the first message + res.writeHead(200, headers).flushHeaders(); + + // Assign the response to the standalone SSE stream + this._streamMapping.set(this._standaloneSseStreamId, res); + // Set up close handler for client disconnects + res.on('close', () => { + this._streamMapping.delete(this._standaloneSseStreamId); + }); + + // Add error handler for standalone SSE stream + res.on('error', error => { + this.onerror?.(error as Error); + }); + } + + /** + * Replays events that would have been sent after the specified event ID + * Only used when resumability is enabled + */ + private async replayEvents(lastEventId: string, res: ServerResponse): Promise { + if (!this._eventStore) { + return; + } + 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) { + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Invalid event ID format' + }, + id: null + }) + ); + return; + } + + // Check conflict with the SAME streamId we'll use for mapping + if (this._streamMapping.get(streamId) !== undefined) { + res.writeHead(409).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Conflict: Stream already has an active connection' + }, + id: null + }) + ); + return; + } + } + + 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; + } + res.writeHead(200, headers).flushHeaders(); + + // Replay events - returns the streamId for backwards compatibility + const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { + send: async (eventId: string, message: JSONRPCMessage) => { + if (!this.writeSSEEvent(res, message, eventId)) { + this.onerror?.(new Error('Failed replay events')); + res.end(); + } + } + }); + + this._streamMapping.set(replayedStreamId, res); + + // Set up close handler for client disconnects + res.on('close', () => { + this._streamMapping.delete(replayedStreamId); + }); + + // Add error handler for replay stream + res.on('error', error => { + this.onerror?.(error as Error); + }); + } catch (error) { + this.onerror?.(error as Error); + } + } + + /** + * Writes an event to the SSE stream with proper formatting + */ + private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean { + 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`; + + return res.write(eventData); + } + + /** + * Handles unsupported requests (PUT, PATCH, etc.) + */ + private async handleUnsupportedRequest(res: ServerResponse): Promise { + res.writeHead(405, { + Allow: 'GET, POST, DELETE' + }).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Method not allowed.' + }, + id: null + }) + ); + } + + /** + * Handles POST requests containing JSON-RPC messages + */ + private async handlePostRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { + try { + // Validate the Accept header + const acceptHeader = req.headers.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')) { + res.writeHead(406).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Not Acceptable: Client must accept both application/json and text/event-stream' + }, + id: null + }) + ); + return; + } + + const ct = req.headers['content-type']; + if (!ct || !ct.includes('application/json')) { + res.writeHead(415).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Unsupported Media Type: Content-Type must be application/json' + }, + id: null + }) + ); + return; + } + + const authInfo: AuthInfo | undefined = req.auth; + const requestInfo: RequestInfo = { headers: req.headers }; + + let rawMessage; + if (parsedBody !== undefined) { + rawMessage = parsedBody; + } else { + const parsedCt = contentType.parse(ct); + const body = await getRawBody(req, { + limit: MAXIMUM_MESSAGE_SIZE, + encoding: parsedCt.parameters.charset ?? 'utf-8' + }); + rawMessage = JSON.parse(body.toString()); + } + + let messages: JSONRPCMessage[]; + + // handle batch and single messages + if (Array.isArray(rawMessage)) { + messages = rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)); + } else { + messages = [JSONRPCMessageSchema.parse(rawMessage)]; + } + + // Check if this is an initialization request + // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ + const isInitializationRequest = messages.some(isInitializeRequest); + 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) { + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Invalid Request: Server already initialized' + }, + id: null + }) + ); + return; + } + if (messages.length > 1) { + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Invalid Request: Only one initialization request is allowed' + }, + id: null + }) + ); + return; + } + 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. + if (!this.validateSession(req, res)) { + return; + } + // Mcp-Protocol-Version header is required for all requests after initialization. + if (!this.validateProtocolVersion(req, res)) { + return; + } + } + + // check if it contains requests + const hasRequests = messages.some(isJSONRPCRequest); + + if (!hasRequests) { + // if it only contains notifications or responses, return 202 + res.writeHead(202).end(); + + // handle each message + for (const message of messages) { + this.onmessage?.(message, { authInfo, requestInfo }); + } + } else if (hasRequests) { + // The default behavior is to use SSE streaming + // but in some cases server will return JSON responses + const streamId = 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['mcp-protocol-version'] as string) ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + + if (!this._enableJsonResponse) { + 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; + } + + res.writeHead(200, headers); + + await this._maybeWritePrimingEvent(res, streamId, clientProtocolVersion); + } + // 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, res); + this._requestToStreamMapping.set(message.id, streamId); + } + } + // Set up close handler for client disconnects + res.on('close', () => { + this._streamMapping.delete(streamId); + }); + + // Add error handler for stream write errors + res.on('error', error => { + this.onerror?.(error as Error); + }); + + // 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, requestInfo, 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 + } + } catch (error) { + // return JSON-RPC formatted error + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32700, + message: 'Parse error', + data: String(error) + }, + id: null + }) + ); + this.onerror?.(error as Error); + } + } + + /** + * Handles DELETE requests to terminate sessions + */ + private async handleDeleteRequest(req: IncomingMessage, res: ServerResponse): Promise { + if (!this.validateSession(req, res)) { + return; + } + if (!this.validateProtocolVersion(req, res)) { + return; + } + await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); + await this.close(); + res.writeHead(200).end(); + } + + /** + * Validates session ID for non-initialization requests + * Returns true if the session is valid, false otherwise + */ + private validateSession(req: IncomingMessage, res: ServerResponse): boolean { + 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 true; + } + if (!this._initialized) { + // If the server has not been initialized yet, reject all requests + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Server not initialized' + }, + id: null + }) + ); + return false; + } + + const sessionId = req.headers['mcp-session-id']; + + if (!sessionId) { + // Non-initialization requests without a session ID should return 400 Bad Request + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Mcp-Session-Id header is required' + }, + id: null + }) + ); + return false; + } else if (Array.isArray(sessionId)) { + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: Mcp-Session-Id header must be a single value' + }, + id: null + }) + ); + return false; + } else if (sessionId !== this.sessionId) { + // Reject requests with invalid session ID with 404 Not Found + res.writeHead(404).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32001, + message: 'Session not found' + }, + id: null + }) + ); + return false; + } + + return true; + } + + /** + * 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: IncomingMessage, res: ServerResponse): boolean { + let protocolVersion = req.headers['mcp-protocol-version']; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + if (protocolVersion !== undefined && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + res.writeHead(400).end( + JSON.stringify({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + }, + id: null + }) + ); + return false; + } + return true; + } + + async close(): Promise { + // Close all SSE connections + this._streamMapping.forEach(response => { + response.end(); + }); + 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.end(); + this._streamMapping.delete(streamId); + } + } + + /** + * 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.end(); + this._streamMapping.delete(this._standaloneSseStreamId); + } + } + + 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 + this.writeSSEEvent(standaloneSse, message, eventId); + return; + } + + // Get the response for this request + const streamId = this._requestToStreamMapping.get(requestId); + const response = this._streamMapping.get(streamId!); + if (!streamId) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + + if (!this._enableJsonResponse) { + // 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); + } + if (response) { + // Write the event to the response stream + this.writeSSEEvent(response, message, eventId); + } + } + + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + this._requestResponseMap.set(requestId, message); + const relatedIds = Array.from(this._requestToStreamMapping.entries()) + .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) + .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 (!response) { + throw new Error(`No connection established for request ID: ${String(requestId)}`); + } + if (this._enableJsonResponse) { + // 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)!); + + response.writeHead(200, headers); + if (responses.length === 1) { + response.end(JSON.stringify(responses[0])); + } else { + response.end(JSON.stringify(responses)); + } + } else { + // End the SSE stream + response.end(); + } + // Clean up + for (const id of relatedIds) { + this._requestResponseMap.delete(id); + this._requestToStreamMapping.delete(id); + } + } + } + } +} diff --git a/packages/server/src/validation/ajv-provider.ts b/packages/server/src/validation/ajv-provider.ts new file mode 100644 index 000000000..115a98521 --- /dev/null +++ b/packages/server/src/validation/ajv-provider.ts @@ -0,0 +1,97 @@ +/** + * AJV-based JSON Schema validator provider + */ + +import { Ajv } from 'ajv'; +import _addFormats from 'ajv-formats'; +import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } 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 + * ```typescript + * // Use with default AJV instance (recommended) + * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * const validator = new AjvJsonSchemaValidator(); + * + * // Use with custom AJV instance + * import { Ajv } from 'ajv'; + * const ajv = new Ajv({ strict: true, allErrors: true }); + * const validator = new AjvJsonSchemaValidator(ajv); + * ``` + */ +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 + * ```typescript + * // Use default configuration (recommended for most cases) + * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * const validator = new AjvJsonSchemaValidator(); + * + * // Or provide custom AJV instance for advanced configuration + * import { Ajv } from 'ajv'; + * import addFormats from 'ajv-formats'; + * + * 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); + + if (valid) { + return { + valid: true, + data: input as T, + errorMessage: undefined + }; + } else { + return { + valid: false, + data: undefined, + errorMessage: this._ajv.errorsText(ajvValidator.errors) + }; + } + }; + } +} diff --git a/packages/server/src/validation/cfworker-provider.ts b/packages/server/src/validation/cfworker-provider.ts new file mode 100644 index 000000000..7e6329d9d --- /dev/null +++ b/packages/server/src/validation/cfworker-provider.ts @@ -0,0 +1,77 @@ +/** + * 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. + */ + +import { Validator } from '@cfworker/json-schema'; +import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; + +/** + * JSON Schema draft version supported by @cfworker/json-schema + */ +export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; + +/** + * + * @example + * ```typescript + * // Use with default configuration (2020-12, shortcircuit) + * const validator = new CfWorkerJsonSchemaValidator(); + * + * // Use with custom configuration + * 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); + + if (result.valid) { + return { + valid: true, + data: input as T, + errorMessage: undefined + }; + } else { + return { + valid: false, + data: undefined, + errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ') + }; + } + }; + } +} diff --git a/packages/server/src/validation/types.ts b/packages/server/src/validation/types.ts new file mode 100644 index 000000000..5864a43f2 --- /dev/null +++ b/packages/server/src/validation/types.ts @@ -0,0 +1,63 @@ +// 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 + * ```typescript + * class MyValidatorProvider implements jsonSchemaValidator { + * getValidator(schema: JsonSchemaType): JsonSchemaValidator { + * // Compile/cache validator from schema + * return (input: unknown) => { + * // Validate input against schema + * if (valid) { + * return { valid: true, data: input as T, errorMessage: undefined }; + * } else { + * return { 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/server/test/.gitkeep b/packages/server/test/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 000000000..84520f682 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 000000000..6ebe3b4ae --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,110 @@ +{ + "name": "@modelcontextprotocol/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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/*" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "typecheck": "tsgo --noEmit", + "build": "npm run build:esm", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@cfworker/json-schema": "^4.1.1", + "@eslint/js": "^9.39.1", + "@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.0", + "@types/express-serve-static-core": "^5.1.0", + "@types/node": "^22.12.0", + "@types/supertest": "^6.0.2", + "@types/ws": "^8.5.12", + "@typescript/native-preview": "^7.0.0-dev.20251103.1", + "eslint": "^9.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.23.1", + "prettier": "3.6.2", + "supertest": "^7.0.0", + "tsx": "^4.16.5", + "typescript": "^5.5.4", + "typescript-eslint": "^8.48.1", + "vitest": "^4.0.8", + "ws": "^8.18.0" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 000000000..f5af7c742 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,15 @@ +export * from './util/inMemory.js'; +export * from './util/zod-compat.js'; +export * from './util/zod-json-schema-compat.js'; + +export * from './shared/auth-utils.js'; +export * from './shared/auth.js'; +export * from './shared/metadataUtils.js'; +export * from './shared/protocol.js'; +export * from './shared/responseMessage.js'; +export * from './shared/stdio.js'; +export * from './shared/toolNameValidation.js'; +export * from './shared/transport.js'; +export * from './shared/uriTemplate.js'; + +export * from './types/types.js'; \ No newline at end of file diff --git a/packages/shared/src/shared/auth-utils.ts b/packages/shared/src/shared/auth-utils.ts new file mode 100644 index 000000000..c9863da43 --- /dev/null +++ b/packages/shared/src/shared/auth-utils.ts @@ -0,0 +1,55 @@ +/** + * Utilities for handling OAuth resource URIs. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * 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 requestedResource The resource URL being requested + * @param 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/shared/src/shared/auth.ts b/packages/shared/src/shared/auth.ts new file mode 100644 index 000000000..c546c8608 --- /dev/null +++ b/packages/shared/src/shared/auth.ts @@ -0,0 +1,231 @@ +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(); + +/** + * 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 SafeUrlSchema that allows empty string for retrocompatibility on tos_uri and logo_uri + */ +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/shared/src/shared/metadataUtils.ts b/packages/shared/src/shared/metadataUtils.ts new file mode 100644 index 000000000..18f84a4c9 --- /dev/null +++ b/packages/shared/src/shared/metadataUtils.ts @@ -0,0 +1,26 @@ +import { BaseMetadata } from '../types.js'; + +/** + * Utilities for working with BaseMetadata objects. + */ + +/** + * Gets the display name for an object with BaseMetadata. + * For tools, the precedence is: title → 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/shared/src/shared/protocol.ts b/packages/shared/src/shared/protocol.ts new file mode 100644 index 000000000..aa242a647 --- /dev/null +++ b/packages/shared/src/shared/protocol.ts @@ -0,0 +1,1657 @@ +import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js'; +import { + CancelledNotificationSchema, + ClientCapabilities, + CreateTaskResultSchema, + ErrorCode, + GetTaskRequest, + GetTaskRequestSchema, + GetTaskResultSchema, + GetTaskPayloadRequest, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + CancelTaskRequestSchema, + CancelTaskResultSchema, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + isJSONRPCNotification, + JSONRPCErrorResponse, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + McpError, + PingRequestSchema, + Progress, + ProgressNotification, + ProgressNotificationSchema, + RELATED_TASK_META_KEY, + RequestId, + Result, + ServerCapabilities, + RequestMeta, + MessageExtraInfo, + RequestInfo, + GetTaskResult, + TaskCreationParams, + RelatedTaskMetadata, + CancelledNotification, + Task, + TaskStatusNotification, + TaskStatusNotificationSchema, + Request, + Notification, + JSONRPCResultResponse, + isTaskAugmentedRequestParams +} from '../types.js'; +import { Transport, TransportSendOptions } from './transport.js'; +import { AuthInfo } from '../server/auth/types.js'; +import { isTerminal, TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; +import { getMethodLiteral, parseWithCompat } from '../server/zod-json-schema-compat.js'; +import { ResponseMessage } from './responseMessage.js'; + +/** + * Callback for progress notifications. + */ +export type ProgressCallback = (progress: Progress) => void; + +/** + * Additional initialization options. + */ +export type ProtocolOptions = { + /** + * 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[]; + /** + * Optional task storage implementation. If provided, enables task-related request handlers + * and provides task storage capabilities to request handlers. + */ + 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 5000ms 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. + * When the limit is exceeded, the TaskMessageQueue implementation's enqueue() method + * will throw an error. It's the implementation's responsibility to handle overflow + * appropriately (e.g., by failing the task, dropping messages, etc.). + */ + maxTaskQueueSize?: number; +}; + +/** + * The default request timeout, in miliseconds. + */ +export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; + +/** + * 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 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 request(). + */ + signal?: AbortSignal; + + /** + * A timeout (in milliseconds) for this request. If exceeded, an McpError with code `RequestTimeout` will be raised from request(). + * + * If not specified, `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 McpError with code `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; +}; + +/** + * 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 }>; +} + +/** + * Extra data given to request handlers. + */ +export type RequestHandlerExtra = { + /** + * An abort signal used to communicate if the request was cancelled from the sender's side. + */ + signal: AbortSignal; + + /** + * Information about a validated access token, provided to request handlers. + */ + authInfo?: AuthInfo; + + /** + * The session ID from the transport, if available. + */ + sessionId?: string; + + /** + * Metadata from the original request. + */ + _meta?: RequestMeta; + + /** + * The JSON-RPC ID of the request being handled. + * This can be useful for tracking or logging purposes. + */ + requestId: RequestId; + + taskId?: string; + + taskStore?: RequestTaskStore; + + taskRequestedTtl?: number | null; + + /** + * The original HTTP request. + */ + requestInfo?: RequestInfo; + + /** + * Sends a notification that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + sendNotification: (notification: SendNotificationT) => Promise; + + /** + * Sends a request that relates to the current request being handled. + * + * This is used by certain transports to correctly associate related messages. + */ + sendRequest: (request: SendRequestT, resultSchema: U, options?: TaskRequestOptions) => Promise>; + + /** + * Closes the SSE stream for this request, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Use this to implement polling behavior during long-running operations. + */ + closeSSEStream?: () => void; + + /** + * Closes the standalone GET SSE stream, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + * Use this to implement polling behavior for server-initiated notifications. + */ + closeStandaloneSSEStream?: () => void; +}; + +/** + * 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. + */ +export abstract class Protocol { + private _transport?: Transport; + private _requestMessageId = 0; + private _requestHandlers: Map< + string, + (request: JSONRPCRequest, extra: RequestHandlerExtra) => 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(); + + // Maps task IDs to progress tokens to keep handlers alive after CreateTaskResult + private _taskProgressTokens: Map = new Map(); + + private _taskStore?: TaskStore; + private _taskMessageQueue?: TaskMessageQueue; + + private _requestResolvers: Map void> = new Map(); + + /** + * Callback for when the connection is closed for any reason. + * + * This is invoked when 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, extra: RequestHandlerExtra) => 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.setNotificationHandler(CancelledNotificationSchema, notification => { + this._oncancel(notification); + }); + + this.setNotificationHandler(ProgressNotificationSchema, notification => { + this._onprogress(notification as unknown as ProgressNotification); + }); + + this.setRequestHandler( + PingRequestSchema, + // Automatic pong by default. + _request => ({}) as SendResultT + ); + + // Install task handlers if TaskStore is provided + this._taskStore = _options?.taskStore; + this._taskMessageQueue = _options?.taskMessageQueue; + if (this._taskStore) { + this.setRequestHandler(GetTaskRequestSchema, async (request, extra) => { + const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); + } + + // Per spec: tasks/get responses SHALL NOT include related-task metadata + // as the taskId parameter is the source of truth + // @ts-expect-error SendResultT cannot contain GetTaskResult, but we include it in our derived types everywhere else + return { + ...task + } as SendResultT; + }); + + this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => { + const handleTaskResult = async (): Promise => { + const taskId = request.params.taskId; + + // Deliver queued messages + if (this._taskMessageQueue) { + let queuedMessage: QueuedMessage | undefined; + while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId))) { + // Handle response and error messages by routing them to the appropriate resolver + if (queuedMessage.type === 'response' || queuedMessage.type === 'error') { + const message = queuedMessage.message; + const requestId = message.id; + + // Lookup resolver in _requestResolvers map + const resolver = this._requestResolvers.get(requestId as RequestId); + + if (resolver) { + // Remove resolver from map after invocation + this._requestResolvers.delete(requestId as RequestId); + + // Invoke resolver with response or error + if (queuedMessage.type === 'response') { + resolver(message as JSONRPCResultResponse); + } else { + // Convert JSONRPCError to McpError + const errorMessage = message as JSONRPCErrorResponse; + const error = new McpError( + errorMessage.error.code, + errorMessage.error.message, + errorMessage.error.data + ); + resolver(error); + } + } else { + // Handle missing resolver gracefully with error logging + const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; + this._onerror(new Error(`${messageType} handler missing for request ${requestId}`)); + } + + // Continue to next message + continue; + } + + // 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 this._transport?.send(queuedMessage.message, { relatedRequestId: extra.requestId }); + } + } + + // Now check task status + const task = await this._taskStore!.getTask(taskId, extra.sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`); + } + + // Block if task is not terminal (we've already delivered all queued messages above) + if (!isTerminal(task.status)) { + // Wait for status change or new messages + await this._waitForTaskUpdate(taskId, extra.signal); + + // After waking up, recursively call to deliver any new messages or result + return await handleTaskResult(); + } + + // If task is terminal, return the result + if (isTerminal(task.status)) { + const result = await this._taskStore!.getTaskResult(taskId, extra.sessionId); + + this._clearTaskQueue(taskId); + + return { + ...result, + _meta: { + ...result._meta, + [RELATED_TASK_META_KEY]: { + taskId: taskId + } + } + } as SendResultT; + } + + return await handleTaskResult(); + }; + + return await handleTaskResult(); + }); + + this.setRequestHandler(ListTasksRequestSchema, async (request, extra) => { + try { + const { tasks, nextCursor } = await this._taskStore!.listTasks(request.params?.cursor, extra.sessionId); + // @ts-expect-error SendResultT cannot contain ListTasksResult, but we include it in our derived types everywhere else + return { + tasks, + nextCursor, + _meta: {} + } as SendResultT; + } catch (error) { + throw new McpError( + ErrorCode.InvalidParams, + `Failed to list tasks: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + + this.setRequestHandler(CancelTaskRequestSchema, async (request, extra) => { + try { + // Get the current task to check if it's in a terminal state, in case the implementation is not atomic + const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); + + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`); + } + + // Reject cancellation of terminal tasks + if (isTerminal(task.status)) { + throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); + } + + await this._taskStore!.updateTaskStatus( + request.params.taskId, + 'cancelled', + 'Client cancelled task execution.', + extra.sessionId + ); + + this._clearTaskQueue(request.params.taskId); + + const cancelledTask = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); + if (!cancelledTask) { + // Task was deleted during cancellation (e.g., cleanup happened) + throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`); + } + + return { + _meta: {}, + ...cancelledTask + } as unknown as SendResultT; + } catch (error) { + // Re-throw McpError as-is + if (error instanceof McpError) { + throw error; + } + throw new McpError( + ErrorCode.InvalidRequest, + `Failed to cancel task: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + } + } + + 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 McpError.fromError(ErrorCode.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 Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. + */ + async connect(transport: Transport): Promise { + this._transport = transport; + const _onclose = this.transport?.onclose; + this._transport.onclose = () => { + _onclose?.(); + 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)}`)); + } + }; + + await this._transport.start(); + } + + private _onclose(): void { + const responseHandlers = this._responseHandlers; + this._responseHandlers = new Map(); + this._progressHandlers.clear(); + this._taskProgressTokens.clear(); + this._pendingDebouncedNotifications.clear(); + + const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed'); + + this._transport = undefined; + this.onclose?.(); + + for (const handler of responseHandlers.values()) { + handler(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; + + // Extract taskId from request metadata if present (needed early for method not found case) + const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId; + + if (handler === undefined) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: request.id, + error: { + code: ErrorCode.MethodNotFound, + message: 'Method not found' + } + }; + + // Queue or send the error response based on whether this is a task-related request + if (relatedTaskId && this._taskMessageQueue) { + this._enqueueTaskMessage( + relatedTaskId, + { + type: 'error', + message: errorResponse, + timestamp: Date.now() + }, + capturedTransport?.sessionId + ).catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); + } else { + capturedTransport + ?.send(errorResponse) + .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); + } + return; + } + + const abortController = new AbortController(); + this._requestHandlerAbortControllers.set(request.id, abortController); + + const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; + const taskStore = this._taskStore ? this.requestTaskStore(request, capturedTransport?.sessionId) : undefined; + + const fullExtra: RequestHandlerExtra = { + signal: abortController.signal, + sessionId: capturedTransport?.sessionId, + _meta: request.params?._meta, + sendNotification: async notification => { + // Include related-task metadata if this request is part of a task + const notificationOptions: NotificationOptions = { relatedRequestId: request.id }; + if (relatedTaskId) { + notificationOptions.relatedTask = { taskId: relatedTaskId }; + } + await this.notification(notification, notificationOptions); + }, + sendRequest: async (r, resultSchema, options?) => { + // Include related-task metadata if this request is part of a task + const requestOptions: RequestOptions = { ...options, relatedRequestId: request.id }; + if (relatedTaskId && !requestOptions.relatedTask) { + requestOptions.relatedTask = { taskId: relatedTaskId }; + } + + // Set task status to input_required when sending a request within a task context + // Use the taskId from options (explicit) or fall back to relatedTaskId (inherited) + const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; + if (effectiveTaskId && taskStore) { + await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); + } + + return await this.request(r, resultSchema, requestOptions); + }, + authInfo: extra?.authInfo, + requestId: request.id, + requestInfo: extra?.requestInfo, + taskId: relatedTaskId, + taskStore: taskStore, + taskRequestedTtl: taskCreationParams?.ttl, + closeSSEStream: extra?.closeSSEStream, + closeStandaloneSSEStream: extra?.closeStandaloneSSEStream + }; + + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. + Promise.resolve() + .then(() => { + // If this request asked for task creation, check capability first + if (taskCreationParams) { + // Check if the request method supports task creation + this.assertTaskHandlerCapability(request.method); + } + }) + .then(() => handler(request, fullExtra)) + .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 + if (relatedTaskId && this._taskMessageQueue) { + await this._enqueueTaskMessage( + relatedTaskId, + { + type: 'response', + message: response, + timestamp: Date.now() + }, + capturedTransport?.sessionId + ); + } else { + 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'] : ErrorCode.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 + if (relatedTaskId && this._taskMessageQueue) { + await this._enqueueTaskMessage( + relatedTaskId, + { + type: 'error', + message: errorResponse, + timestamp: Date.now() + }, + capturedTransport?.sessionId + ); + } else { + await capturedTransport?.send(errorResponse); + } + } + ) + .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) + .finally(() => { + 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); + + // Check if this is a response to a queued request + const resolver = this._requestResolvers.get(messageId); + if (resolver) { + this._requestResolvers.delete(messageId); + if (isJSONRPCResultResponse(response)) { + resolver(response); + } else { + const error = new McpError(response.error.code, response.error.message, response.error.data); + resolver(error); + } + return; + } + + 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 + let isTaskResponse = false; + 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') { + isTaskResponse = true; + this._taskProgressTokens.set(task.taskId, messageId); + } + } + } + + if (!isTaskResponse) { + this._progressHandlers.delete(messageId); + } + + if (isJSONRPCResultResponse(response)) { + handler(response); + } else { + const error = McpError.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: SendRequestT['method']): 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: SendNotificationT['method']): 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 task creation is supported for the given request method. + * + * This should be implemented by subclasses. + */ + protected abstract assertTaskCapability(method: string): void; + + /** + * A method to check if task handler is supported by the local side, for the given method to be handled. + * + * This should be implemented by subclasses. + */ + protected abstract assertTaskHandlerCapability(method: string): void; + + /** + * Sends a request and returns an AsyncGenerator that yields response messages. + * The generator is guaranteed to end with either a 'result' or 'error' message. + * + * @example + * ```typescript + * const stream = protocol.requestStream(request, resultSchema, 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; + * } + * } + * ``` + * + * @experimental Use `client.experimental.tasks.requestStream()` to access this method. + */ + protected async *requestStream( + request: SendRequestT, + resultSchema: T, + options?: RequestOptions + ): AsyncGenerator>, void, void> { + const { task } = options ?? {}; + + // For non-task requests, just yield the result + if (!task) { + try { + const result = await this.request(request, resultSchema, options); + yield { type: 'result', result }; + } catch (error) { + yield { + type: 'error', + error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error)) + }; + } + return; + } + + // For task-augmented requests, we need to poll for status + // First, make the request to create the task + let taskId: string | undefined; + try { + // Send the request and get the CreateTaskResult + const createResult = await this.request(request, CreateTaskResultSchema, options); + + // Extract taskId from the result + if (createResult.task) { + taskId = createResult.task.taskId; + yield { type: 'taskCreated', task: createResult.task }; + } else { + throw new McpError(ErrorCode.InternalError, 'Task creation did not return a task'); + } + + // Poll for task completion + while (true) { + // Get current task status + const task = await this.getTask({ taskId }, options); + yield { type: 'taskStatus', task }; + + // Check if task is terminal + if (isTerminal(task.status)) { + if (task.status === 'completed') { + // Get the final result + const result = await this.getTaskResult({ taskId }, resultSchema, options); + yield { type: 'result', result }; + } else if (task.status === 'failed') { + yield { + type: 'error', + error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`) + }; + } else if (task.status === 'cancelled') { + yield { + type: 'error', + error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`) + }; + } + return; + } + + // When input_required, call tasks/result to deliver queued messages + // (elicitation, sampling) via SSE and block until terminal + if (task.status === 'input_required') { + const result = await this.getTaskResult({ taskId }, resultSchema, options); + yield { type: 'result', result }; + return; + } + + // Wait before polling again + const pollInterval = task.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1000; + await new Promise(resolve => setTimeout(resolve, pollInterval)); + + // Check if cancelled + options?.signal?.throwIfAborted(); + } + } catch (error) { + yield { + type: 'error', + error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error)) + }; + } + } + + /** + * Sends a request and waits for a response. + * + * Do not use this method to emit notifications! Use notification() instead. + */ + request(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise> { + const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; + + // 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); + + // If task creation is requested, also check task capabilities + if (task) { + this.assertTaskCapability(request.method); + } + } catch (e) { + earlyReject(e); + return; + } + } + + options?.signal?.throwIfAborted(); + + const messageId = this._requestMessageId++; + 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 + } + }; + } + + // Augment with task creation parameters if provided + if (task) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + task: task + }; + } + + // Augment with related task metadata if relatedTask is provided + if (relatedTask) { + jsonrpcRequest.params = { + ...jsonrpcRequest.params, + _meta: { + ...(jsonrpcRequest.params?._meta || {}), + [RELATED_TASK_META_KEY]: relatedTask + } + }; + } + + const cancel = (reason: unknown) => { + this._responseHandlers.delete(messageId); + this._progressHandlers.delete(messageId); + this._cleanupTimeout(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 McpError if it isn't already + const error = reason instanceof McpError ? reason : new McpError(ErrorCode.RequestTimeout, String(reason)); + reject(error); + }; + + this._responseHandlers.set(messageId, response => { + if (options?.signal?.aborted) { + return; + } + + if (response instanceof Error) { + return reject(response); + } + + try { + const parseResult = safeParse(resultSchema, response.result); + if (!parseResult.success) { + // Type guard: if success is false, error is guaranteed to exist + reject(parseResult.error); + } else { + resolve(parseResult.data as SchemaOutput); + } + } catch (error) { + reject(error); + } + }); + + options?.signal?.addEventListener('abort', () => { + cancel(options?.signal?.reason); + }); + + const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); + + this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); + + // Queue request if related to a task + const relatedTaskId = relatedTask?.taskId; + if (relatedTaskId) { + // Store the response resolver for this request so responses can be routed back + const responseResolver = (response: JSONRPCResultResponse | Error) => { + const handler = this._responseHandlers.get(messageId); + if (handler) { + handler(response); + } else { + // Log error when resolver is missing, but don't fail + this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); + } + }; + this._requestResolvers.set(messageId, responseResolver); + + this._enqueueTaskMessage(relatedTaskId, { + type: 'request', + message: jsonrpcRequest, + timestamp: Date.now() + }).catch(error => { + this._cleanupTimeout(messageId); + reject(error); + }); + + // Don't send through transport - queued messages are delivered via tasks/result only + // This prevents duplicate delivery for bidirectional transports + } else { + // No related task - send through transport normally + this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { + this._cleanupTimeout(messageId); + reject(error); + }); + } + }); + } + + /** + * Gets the current status of a task. + * + * @experimental Use `client.experimental.tasks.getTask()` to access this method. + */ + protected async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { + // @ts-expect-error SendRequestT cannot directly contain GetTaskRequest, but we ensure all type instantiations contain it anyways + return this.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); + } + + /** + * Retrieves the result of a completed task. + * + * @experimental Use `client.experimental.tasks.getTaskResult()` to access this method. + */ + protected async getTaskResult( + params: GetTaskPayloadRequest['params'], + resultSchema: T, + options?: RequestOptions + ): Promise> { + // @ts-expect-error SendRequestT cannot directly contain GetTaskPayloadRequest, but we ensure all type instantiations contain it anyways + return this.request({ method: 'tasks/result', params }, resultSchema, options); + } + + /** + * Lists tasks, optionally starting from a pagination cursor. + * + * @experimental Use `client.experimental.tasks.listTasks()` to access this method. + */ + protected async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { + // @ts-expect-error SendRequestT cannot directly contain ListTasksRequest, but we ensure all type instantiations contain it anyways + return this.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); + } + + /** + * Cancels a specific task. + * + * @experimental Use `client.experimental.tasks.cancelTask()` to access this method. + */ + protected async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { + // @ts-expect-error SendRequestT cannot directly contain CancelTaskRequest, but we ensure all type instantiations contain it anyways + return this.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); + } + + /** + * Emits a notification, which is a one-way message that does not expect a response. + */ + async notification(notification: SendNotificationT, options?: NotificationOptions): Promise { + if (!this._transport) { + throw new Error('Not connected'); + } + + this.assertNotificationCapability(notification.method); + + // Queue notification if related to a task + const relatedTaskId = options?.relatedTask?.taskId; + if (relatedTaskId) { + // Build the JSONRPC notification with metadata + 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() + }); + + // Don't send through transport - queued messages are delivered via tasks/result only + // This prevents duplicate delivery for bidirectional transports + 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; + } + + let jsonrpcNotification: JSONRPCNotification = { + ...notification, + jsonrpc: '2.0' + }; + + // Augment with related task metadata if relatedTask is provided + if (options?.relatedTask) { + jsonrpcNotification = { + ...jsonrpcNotification, + params: { + ...jsonrpcNotification.params, + _meta: { + ...(jsonrpcNotification.params?._meta || {}), + [RELATED_TASK_META_KEY]: options.relatedTask + } + } + }; + } + + // 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; + } + + let jsonrpcNotification: JSONRPCNotification = { + ...notification, + jsonrpc: '2.0' + }; + + // Augment with related task metadata if relatedTask is provided + if (options?.relatedTask) { + jsonrpcNotification = { + ...jsonrpcNotification, + params: { + ...jsonrpcNotification.params, + _meta: { + ...(jsonrpcNotification.params?._meta || {}), + [RELATED_TASK_META_KEY]: options.relatedTask + } + } + }; + } + + 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. + */ + setRequestHandler( + requestSchema: T, + handler: ( + request: SchemaOutput, + extra: RequestHandlerExtra + ) => SendResultT | Promise + ): void { + const method = getMethodLiteral(requestSchema); + this.assertRequestHandlerCapability(method); + + this._requestHandlers.set(method, (request, extra) => { + const parsed = parseWithCompat(requestSchema, request) as SchemaOutput; + return Promise.resolve(handler(parsed, extra)); + }); + } + + /** + * Removes the request handler for the given method. + */ + removeRequestHandler(method: 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: 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. + */ + setNotificationHandler( + notificationSchema: T, + handler: (notification: SchemaOutput) => void | Promise + ): void { + const method = getMethodLiteral(notificationSchema); + this._notificationHandlers.set(method, notification => { + const parsed = parseWithCompat(notificationSchema, notification) as SchemaOutput; + return Promise.resolve(handler(parsed)); + }); + } + + /** + * Removes the notification handler for the given method. + */ + removeNotificationHandler(method: string): void { + this._notificationHandlers.delete(method); + } + + /** + * Cleans up the progress handler associated with a task. + * This should be called when a task reaches a terminal status. + */ + private _cleanupTaskProgressHandler(taskId: string): void { + const progressToken = this._taskProgressTokens.get(taskId); + if (progressToken !== undefined) { + this._progressHandlers.delete(progressToken); + this._taskProgressTokens.delete(taskId); + } + } + + /** + * Enqueues a task-related message for side-channel delivery via tasks/result. + * @param taskId The task ID to associate the message with + * @param message The message to enqueue + * @param sessionId Optional session ID for binding the operation to a specific session + * @throws Error if taskStore is not configured or if enqueue fails (e.g., queue overflow) + * + * Note: If enqueue fails, it's the TaskMessageQueue implementation's responsibility to handle + * the error appropriately (e.g., by failing the task, logging, etc.). The Protocol layer + * simply propagates the error. + */ + private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise { + // Task message queues are only used when taskStore is configured + if (!this._taskStore || !this._taskMessageQueue) { + throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured'); + } + + const maxQueueSize = this._options?.maxTaskQueueSize; + await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize); + } + + /** + * Clears the message queue for a task and rejects any pending request resolvers. + * @param taskId The task ID whose queue should be cleared + * @param sessionId Optional session ID for binding the operation to a specific session + */ + private async _clearTaskQueue(taskId: string, sessionId?: string): Promise { + if (this._taskMessageQueue) { + // Reject any pending request resolvers + const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); + for (const message of messages) { + if (message.type === 'request' && isJSONRPCRequest(message.message)) { + // Extract request ID from the message + const requestId = message.message.id as RequestId; + const resolver = this._requestResolvers.get(requestId); + if (resolver) { + resolver(new McpError(ErrorCode.InternalError, 'Task cancelled or completed')); + this._requestResolvers.delete(requestId); + } else { + // Log error when resolver is missing during cleanup for better observability + this._onerror(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); + } + } + } + } + } + + /** + * Waits for a task update (new messages or status change) with abort signal support. + * Uses polling to check for updates at the task's configured poll interval. + * @param taskId The task ID to wait for + * @param signal Abort signal to cancel the wait + * @returns Promise that resolves when an update occurs or rejects if aborted + */ + private async _waitForTaskUpdate(taskId: string, signal: AbortSignal): Promise { + // Get the task's poll interval, falling back to default + let interval = this._options?.defaultTaskPollInterval ?? 1000; + try { + const task = await this._taskStore?.getTask(taskId); + if (task?.pollInterval) { + interval = task.pollInterval; + } + } catch { + // Use default interval if task lookup fails + } + + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled')); + return; + } + + // Wait for the poll interval, then resolve so caller can check for updates + const timeoutId = setTimeout(resolve, interval); + + // Clean up timeout and reject if aborted + signal.addEventListener( + 'abort', + () => { + clearTimeout(timeoutId); + reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled')); + }, + { once: true } + ); + }); + } + + private requestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { + const taskStore = this._taskStore; + if (!taskStore) { + throw new Error('No task store configured'); + } + + 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 McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); + } + + return task; + }, + storeTaskResult: async (taskId, status, result) => { + await taskStore.storeTaskResult(taskId, status, result, sessionId); + + // Get updated task state and send notification + const task = await taskStore.getTask(taskId, sessionId); + if (task) { + const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ + method: 'notifications/tasks/status', + params: task + }); + await this.notification(notification as SendNotificationT); + + if (isTerminal(task.status)) { + this._cleanupTaskProgressHandler(taskId); + // Don't clear queue here - it will be cleared after delivery via tasks/result + } + } + }, + getTaskResult: taskId => { + return taskStore.getTaskResult(taskId, sessionId); + }, + updateTaskStatus: async (taskId, status, statusMessage) => { + // Check if task exists + const task = await taskStore.getTask(taskId, sessionId); + if (!task) { + throw new McpError(ErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); + } + + // Don't allow transitions from terminal states + if (isTerminal(task.status)) { + throw new McpError( + ErrorCode.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); + + // Get updated task state and send notification + const updatedTask = await taskStore.getTask(taskId, sessionId); + if (updatedTask) { + const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ + method: 'notifications/tasks/status', + params: updatedTask + }); + await this.notification(notification as SendNotificationT); + + if (isTerminal(updatedTask.status)) { + this._cleanupTaskProgressHandler(taskId); + // Don't clear queue here - it will be cleared after delivery via tasks/result + } + } + }, + listTasks: cursor => { + return taskStore.listTasks(cursor, sessionId); + } + }; + } +} + +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]; + if (isPlainObject(baseValue) && isPlainObject(addValue)) { + result[k] = { ...(baseValue as Record), ...(addValue as Record) } as T[typeof k]; + } else { + result[k] = addValue as T[typeof k]; + } + } + return result; +} diff --git a/packages/shared/src/shared/responseMessage.ts b/packages/shared/src/shared/responseMessage.ts new file mode 100644 index 000000000..6fefcf1f6 --- /dev/null +++ b/packages/shared/src/shared/responseMessage.ts @@ -0,0 +1,70 @@ +import { Result, Task, McpError } from '../types.js'; + +/** + * Base message type + */ +export interface BaseResponseMessage { + type: string; +} + +/** + * Task status update message + */ +export interface TaskStatusMessage extends BaseResponseMessage { + type: 'taskStatus'; + task: Task; +} + +/** + * Task created message (first message for task-augmented requests) + */ +export interface TaskCreatedMessage extends BaseResponseMessage { + type: 'taskCreated'; + task: Task; +} + +/** + * Final result message (terminal) + */ +export interface ResultMessage extends BaseResponseMessage { + type: 'result'; + result: T; +} + +/** + * Error message (terminal) + */ +export interface ErrorMessage extends BaseResponseMessage { + type: 'error'; + error: McpError; +} + +/** + * Union type representing all possible messages that can be yielded during request processing. + * Note: Progress notifications are handled through the existing onprogress callback mechanism. + * 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; + +export async function toArrayAsync>(it: T): Promise[]> { + const arr: AsyncGeneratorValue[] = []; + for await (const o of it) { + arr.push(o as AsyncGeneratorValue); + } + + return arr; +} + +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/shared/src/shared/stdio.ts b/packages/shared/src/shared/stdio.ts new file mode 100644 index 000000000..fe14612bd --- /dev/null +++ b/packages/shared/src/shared/stdio.ts @@ -0,0 +1,39 @@ +import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.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 { + if (!this._buffer) { + return null; + } + + 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); + return deserializeMessage(line); + } + + 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/shared/src/shared/toolNameValidation.ts b/packages/shared/src/shared/toolNameValidation.ts new file mode 100644 index 000000000..fa96afde0 --- /dev/null +++ b/packages/shared/src/shared/toolNameValidation.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * 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 + .split('') + .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/shared/src/shared/transport.ts b/packages/shared/src/shared/transport.ts new file mode 100644 index 000000000..359d59bfc --- /dev/null +++ b/packages/shared/src/shared/transport.ts @@ -0,0 +1,128 @@ +import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/types.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: HeadersInit | 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; + + /** + * 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; + + /** + * 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; +}; +/** + * 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 Client, Server, or Protocol classes, as they will implicitly call 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 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; + + /** + * Callback for when a message (request or response) is received over the connection. + * + * Includes the requestInfo and authInfo if the transport is authenticated. + * + * The requestInfo can be used to get the original request information (headers, etc.) + */ + onmessage?: (message: T, extra?: MessageExtraInfo) => void; + + /** + * The session ID generated for this connection. + */ + sessionId?: string; + + /** + * Sets the protocol version used for the connection (called when the initialize response is received). + */ + setProtocolVersion?: (version: string) => void; +} diff --git a/packages/shared/src/shared/uriTemplate.ts b/packages/shared/src/shared/uriTemplate.ts new file mode 100644 index 000000000..1dd57f56f --- /dev/null +++ b/packages/shared/src/shared/uriTemplate.ts @@ -0,0 +1,287 @@ +// Claude-authored implementation of RFC 6570 URI Templates + +export type Variables = Record; + +const MAX_TEMPLATE_LENGTH = 1000000; // 1MB +const MAX_VARIABLE_LENGTH = 1000000; // 1MB +const MAX_TEMPLATE_EXPRESSIONS = 10000; +const MAX_REGEX_LENGTH = 1000000; // 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 + if ((part.operator === '?' || part.operator === '&') && hasQueryParam) { + result += expanded.replace('?', '&'); + } else { + result += expanded; + } + + if (part.operator === '?' || part.operator === '&') { + hasQueryParam = true; + } + } + + return result; + } + + private escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + 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 = '\\.([^/,]+)'; + 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 (let i = 0; i < names.length; i++) { + const { name, exploded } = names[i]; + const value = match[i + 1]; + const cleanName = name.replace('*', ''); + + if (exploded && value.includes(',')) { + result[cleanName] = value.split(','); + } else { + result[cleanName] = value; + } + } + + return result; + } +} diff --git a/packages/shared/src/types/spec.types.ts b/packages/shared/src/types/spec.types.ts new file mode 100644 index 000000000..07a1cceff --- /dev/null +++ b/packages/shared/src/types/spec.types.ts @@ -0,0 +1,2587 @@ +/** + * 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: 35fa160caf287a9c48696e3ae452c0645c713669 + * + * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. + * To update this file, run: npm run fetch:spec-types + *//* 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"; + +/** + * 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 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?: TaskMetadata; +} +/** + * Common params for any request. + * + * @internal + */ +export interface RequestParams { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * 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?: ProgressToken; + [key: string]: unknown; + }; +} + +/** @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 }; +} + +/** @internal */ +export interface NotificationParams { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** @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 }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * @category Common Types + */ +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. + */ +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; + +// 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. + * + * @internal + */ +export interface URLElicitationRequiredError + extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @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 `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 `tasks/cancel` request instead of this notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: "notifications/cancelled"; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @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. + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: "initialize"; + params: InitializeRequestParams; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @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. + * + * 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?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @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]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: { + /** + * Whether the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: object; + /** + * Whether the client supports tool use via tools and toolChoice parameters. + */ + tools?: object; + }; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: { form?: object; url?: object }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports tasks/list. + */ + list?: object; + /** + * Whether this client supports tasks/cancel. + */ + cancel?: object; + /** + * 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?: object; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented elicitation/create requests. + */ + create?: object; + }; + }; + }; +} + +/** + * 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]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + 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. + */ + 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 tasks/list. + */ + list?: object; + /** + * Whether this server supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented tools/call requests. + */ + call?: object; + }; + }; + }; +} + +/** + * 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 takes 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 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 { + 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. + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: "ping"; + params?: RequestParams; +} + +/* Progress notifications */ + +/** + * Parameters for a `notifications/progress` notification. + * + * @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. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: "notifications/progress"; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common parameters for paginated requests. + * + * @internal + */ +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. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: "resources/list"; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: "resources/templates/list"; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Common parameters when working with resources. + * + * @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. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: "resources/read"; + params: ReadResourceRequestParams; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * 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. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: "notifications/resources/list_changed"; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: "resources/subscribe"; + params: SubscribeRequestParams; +} + +/** + * 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: "resources/unsubscribe"; + params: UnsubscribeRequestParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @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 resources/subscribe request. + * + * @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. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @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; +} + +/** + * @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. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: "prompts/list"; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Parameters for a `prompts/get` request. + * + * @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. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: "prompts/get"; + params: GetPromptRequestParams; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * 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[]; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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 `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 `resources/list` requests. + * + * @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. + * + * @category Content + */ +export interface EmbeddedResource { + type: "resource"; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * 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. + * + * @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. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: "tools/list"; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @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; +} + +/** + * Parameters for a `tools/call` request. + * + * @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. + * + * @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. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: "notifications/tools/list_changed"; + params?: NotificationParams; +} + +/** + * Additional properties describing a 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. + * + * @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]: object }; + 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 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]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* 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. + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * A response to a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * 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 response to a tasks/get request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * 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 response to a tasks/result request. + * The structure matches the result type of the original request. + * For example, a tools/call task would return the CallToolResult structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * 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 response to a tasks/cancel request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: "tasks/list"; +} + +/** + * The response to a tasks/list request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * 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. + * + * @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 notifications/message. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: "logging/setLevel"; + params: SetLevelRequestParams; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @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. + * + * @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. + * + * @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 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?: object; + /** + * 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?: Tool[]; + /** + * 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?: 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. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: "sampling/createMessage"; + params: CreateMessageRequestParams; +} + +/** + * The client's response to a sampling/createMessage request from the server. + * 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. + * + * @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; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +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. + * + * @category Content + */ +export interface TextContent { + type: "text"; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A request from the assistant to call a tool. + * + * @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. + * + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @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 ToolUseContent. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as 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 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. + * + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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. + * + * @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` + */ +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. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: "completion/complete"; + params: CompleteRequestParams; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + 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 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. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: "roots/list"; + params?: RequestParams; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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 ListRootsRequest. + * + * @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. + * + * @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. + * + * @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. + * + * @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; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: "string"; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: "email" | "uri" | "date" | "date-time"; + default?: string; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: "number" | "integer"; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: "boolean"; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @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. + * + * @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. + * + * @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. + * + * @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 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 client's response to an elicitation request. + * + * @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 decline 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[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @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 + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts new file mode 100644 index 000000000..33350ab38 --- /dev/null +++ b/packages/shared/src/types/types.ts @@ -0,0 +1,2593 @@ +import * as z from 'zod/v4'; + +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'; + +/** + * 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; +} + +/** + * Utility types + */ +type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; +/** + * Assert 'object' type schema. + * + * @internal + */ +const AssertObjectSchema = z.custom((v): v is object => v !== null && (typeof v === 'object' || typeof v === 'function')); +/** + * 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({ + /** + * 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()]).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() +}); + +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. + */ +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() +}); + +/** + * Checks if a value is a valid TaskAugmentedRequestParams. + * @param value - The value to check. + * + * @returns True if the value is a valid TaskAugmentedRequestParams, false otherwise. + */ +export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => + TaskAugmentedRequestParamsSchema.safeParse(value).success; + +export const RequestSchema = z.object({ + method: z.string(), + params: BaseRequestParamsSchema.loose().optional() +}); + +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(); + +export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => JSONRPCRequestSchema.safeParse(value).success; + +/** + * A notification which does not expect a response. + */ +export const JSONRPCNotificationSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + ...NotificationSchema.shape + }) + .strict(); + +export const isJSONRPCNotification = (value: unknown): value is JSONRPCNotification => JSONRPCNotificationSchema.safeParse(value).success; + +/** + * A successful (non-error) response to a request. + */ +export const JSONRPCResultResponseSchema = z + .object({ + jsonrpc: z.literal(JSONRPC_VERSION), + id: RequestIdSchema, + result: ResultSchema + }) + .strict(); + +export const isJSONRPCResultResponse = (value: unknown): value is JSONRPCResultResponse => + JSONRPCResultResponseSchema.safeParse(value).success; + +/** + * Error codes defined by the JSON-RPC specification. + */ +export enum ErrorCode { + // SDK error codes + ConnectionClosed = -32000, + RequestTimeout = -32001, + + // Standard JSON-RPC error codes + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + + // MCP-specific error codes + UrlElicitationRequired = -32042 +} + +/** + * 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 isJSONRPCErrorResponse = (value: unknown): value is JSONRPCErrorResponse => + JSONRPCErrorResponseSchema.safeParse(value).success; + +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 `initialize` request. + */ +export const CancelledNotificationSchema = NotificationSchema.extend({ + method: z.literal('notifications/cancelled'), + params: CancelledNotificationParamsSchema +}); + +/* Base Metadata */ +/** + * Icon schema for use in tools, prompts, resources, and 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() +}); + +/** + * 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 resources, tools, prompts, and 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() +}); + +const FormElicitationCapabilitySchema = z.intersection( + z.object({ + applyDefaults: z.boolean().optional() + }), + z.record(z.string(), z.unknown()) +); + +const ElicitationCapabilitySchema = z.preprocess( + value => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (Object.keys(value as Record).length === 0) { + return { form: {} }; + } + } + return value; + }, + z.intersection( + z.object({ + form: FormElicitationCapabilitySchema.optional(), + url: AssertObjectSchema.optional() + }), + z.record(z.string(), z.unknown()).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: AssertObjectSchema.optional(), + /** + * Present if the client supports cancelling tasks. + */ + cancel: AssertObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for sampling requests. + */ + sampling: z + .looseObject({ + createMessage: AssertObjectSchema.optional() + }) + .optional(), + /** + * Task support for elicitation requests. + */ + elicitation: z + .looseObject({ + create: AssertObjectSchema.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: AssertObjectSchema.optional(), + /** + * Present if the server supports cancelling tasks. + */ + cancel: AssertObjectSchema.optional(), + /** + * Capabilities for task creation on specific request types. + */ + requests: z + .looseObject({ + /** + * Task support for tool requests. + */ + tools: z + .looseObject({ + call: AssertObjectSchema.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(), AssertObjectSchema).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: AssertObjectSchema.optional(), + /** + * Present if the client supports tool use via tools and toolChoice parameters. + */ + tools: AssertObjectSchema.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() +}); + +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 +}); + +export const isInitializeRequest = (value: unknown): value is InitializeRequest => InitializeRequestSchema.safeParse(value).success; + +/** + * 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(), AssertObjectSchema).optional(), + /** + * Present if the server supports sending log messages to the client. + */ + logging: AssertObjectSchema.optional(), + /** + * Present if the server supports sending completions to the client. + */ + completions: AssertObjectSchema.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() +}); + +/** + * 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() +}); + +export const isInitializedNotification = (value: unknown): value is InitializedNotification => + InitializedNotificationSchema.safeParse(value).success; + +/* 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 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 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 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 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()), + + /** + * 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 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 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 `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 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. + */ +export const UnsubscribeRequestSchema = RequestSchema.extend({ + method: z.literal('resources/unsubscribe'), + params: UnsubscribeRequestParamsSchema +}); + +/** + * Parameters for a `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 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 prompts/list request from the client. + */ +export const ListPromptsResultSchema = PaginatedResultSchema.extend({ + prompts: z.array(PromptSchema) +}); + +/** + * Parameters for a `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() +}); + +/** + * An Audio 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 `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 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 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 the 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(), AssertObjectSchema).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(), AssertObjectSchema).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 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() +}); + +/** + * 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() +}); + +/** + * Callback type for list changed notifications. + */ +export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; + +/** + * 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) +}); + +/** + * 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; +}; + +/* 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 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: AssertObjectSchema.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 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() + }) +}); + +/** + * Parameters for an `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 `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 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 decline 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() +}); + +/** + * @deprecated Use ResourceTemplateReferenceSchema instead + */ +export const ResourceReferenceSchema = ResourceTemplateReferenceSchema; + +/** + * 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 `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 +}); + +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); +} + +/** + * The server's response to a 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 +]); + +export class McpError extends Error { + constructor( + public readonly code: number, + message: string, + public readonly data?: unknown + ) { + super(`MCP error ${code}: ${message}`); + this.name = 'McpError'; + } + + /** + * Factory method to create the appropriate error type based on the error code and data + */ + static fromError(code: number, message: string, data?: unknown): McpError { + // Check for specific error types + if (code === ErrorCode.UrlElicitationRequired && data) { + const errorData = data as { elicitations?: unknown[] }; + if (errorData.elicitations) { + return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message); + } + } + + // Default to generic McpError + return new McpError(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 McpError { + constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) { + super(ErrorCode.UrlElicitationRequired, message, { + elicitations: elicitations + }); + } + + get elicitations(): ElicitRequestURLParams[] { + return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? []; + } +} + +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>; + +/** + * Headers that are compatible with both Node.js and the browser. + */ +export type IsomorphicHeaders = Record; + +/** + * Information about the incoming request. + */ +export interface RequestInfo { + /** + * The headers of the request. + */ + headers: IsomorphicHeaders; +} + +/** + * Extra information about a message. + */ +export interface MessageExtraInfo { + /** + * The request information. + */ + requestInfo?: RequestInfo; + + /** + * The authentication information. + */ + authInfo?: AuthInfo; + + /** + * Callback to close the SSE stream for this request, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + */ + closeSSEStream?: () => void; + + /** + * Callback to close the standalone GET SSE stream, triggering client reconnection. + * Only available when using StreamableHTTPServerTransport with eventStore configured. + */ + closeStandaloneSSEStream?: () => void; +} + +/* 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; + +/** + * CreateMessageRequestParams without tools - for backwards-compatible overload. + * Excludes tools/toolChoice to indicate they should not be provided. + */ +export type CreateMessageRequestParamsBase = Omit; + +/** + * CreateMessageRequestParams with required tools - for tool-enabled overload. + */ +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + tools: Tool[]; +} + +/* 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; +/** + * @deprecated Use ResourceTemplateReference instead + */ +export type ResourceReference = ResourceTemplateReference; +export type PromptReference = Infer; +export type CompleteRequestParams = Infer; +export type CompleteRequest = Infer; +export type CompleteRequestResourceTemplate = ExpandRecursively< + CompleteRequest & { params: CompleteRequestParams & { ref: ResourceTemplateReference } } +>; +export type CompleteRequestPrompt = ExpandRecursively; +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; diff --git a/packages/shared/src/util/inMemory.ts b/packages/shared/src/util/inMemory.ts new file mode 100644 index 000000000..ca64f10fa --- /dev/null +++ b/packages/shared/src/util/inMemory.ts @@ -0,0 +1,63 @@ +import { Transport } from '../shared/transport.js'; +import { JSONRPCMessage, RequestId } from '../types/types.js'; +import { AuthInfo } from '../types/types.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. + */ +export class InMemoryTransport implements Transport { + private _otherTransport?: InMemoryTransport; + private _messageQueue: QueuedMessage[] = []; + + 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 Client and one to a 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 { + const other = this._otherTransport; + this._otherTransport = undefined; + await other?.close(); + 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 Error('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/shared/src/util/zod-compat.ts b/packages/shared/src/util/zod-compat.ts new file mode 100644 index 000000000..04ee5361f --- /dev/null +++ b/packages/shared/src/util/zod-compat.ts @@ -0,0 +1,280 @@ +// zod-compat.ts +// ---------------------------------------------------- +// Unified types + helpers to accept Zod v3 and v4 (Mini) +// ---------------------------------------------------- + +import type * as z3 from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; + +import * as z3rt from 'zod/v3'; +import * as z4mini from 'zod/v4-mini'; + +// --- Unified schema types --- +export type AnySchema = z3.ZodTypeAny | z4.$ZodType; +export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject | AnySchema; +export type ZodRawShapeCompat = Record; + +// --- Internal property access helpers --- +// These types help us safely access internal properties that differ between v3 and v4 +export interface ZodV3Internal { + _def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + shape?: Record | (() => Record); + value?: unknown; +} + +export interface ZodV4Internal { + _zod?: { + def?: { + type?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + }; + value?: unknown; +} + +// --- Type inference helpers --- +export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends z4.$ZodType ? z4.output : never; + +export type SchemaInput = S extends z3.ZodTypeAny ? z3.input : S extends z4.$ZodType ? z4.input : never; + +/** + * Infers the output type from a ZodRawShapeCompat (raw shape object). + * Maps over each key in the shape and infers the output type from each schema. + */ +export type ShapeOutput = { + [K in keyof Shape]: SchemaOutput; +}; + +// --- Runtime detection --- +export function isZ4Schema(s: AnySchema): s is z4.$ZodType { + // Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3 + const schema = s as unknown as ZodV4Internal; + return !!schema._zod; +} + +// --- Schema construction --- +export function objectFromShape(shape: ZodRawShapeCompat): AnyObjectSchema { + const values = Object.values(shape); + if (values.length === 0) return z4mini.object({}); // default to v4 Mini + + const allV4 = values.every(isZ4Schema); + const allV3 = values.every(s => !isZ4Schema(s)); + + if (allV4) return z4mini.object(shape as Record); + if (allV3) return z3rt.object(shape as Record); + + throw new Error('Mixed Zod versions detected in object shape.'); +} + +// --- Unified parsing --- +export function safeParse( + schema: S, + data: unknown +): { success: true; data: SchemaOutput } | { success: false; error: unknown } { + if (isZ4Schema(schema)) { + // Mini exposes top-level safeParse + const result = z4mini.safeParse(schema, data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + } + const v3Schema = schema as z3.ZodTypeAny; + const result = v3Schema.safeParse(data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; +} + +export async function safeParseAsync( + schema: S, + data: unknown +): Promise<{ success: true; data: SchemaOutput } | { success: false; error: unknown }> { + if (isZ4Schema(schema)) { + // Mini exposes top-level safeParseAsync + const result = await z4mini.safeParseAsync(schema, data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; + } + const v3Schema = schema as z3.ZodTypeAny; + const result = await v3Schema.safeParseAsync(data); + return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; +} + +// --- Shape extraction --- +export function getObjectShape(schema: AnyObjectSchema | undefined): Record | undefined { + if (!schema) return undefined; + + // Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape` + let rawShape: Record | (() => Record) | undefined; + + if (isZ4Schema(schema)) { + const v4Schema = schema as unknown as ZodV4Internal; + rawShape = v4Schema._zod?.def?.shape; + } else { + const v3Schema = schema as unknown as ZodV3Internal; + rawShape = v3Schema.shape; + } + + if (!rawShape) return undefined; + + if (typeof rawShape === 'function') { + try { + return rawShape(); + } catch { + return undefined; + } + } + + return rawShape; +} + +// --- Schema normalization --- +/** + * Normalizes a schema to an object schema. Handles both: + * - Already-constructed object schemas (v3 or v4) + * - Raw shapes that need to be wrapped into object schemas + */ +export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | undefined): AnyObjectSchema | undefined { + if (!schema) return undefined; + + // First check if it's a raw shape (Record) + // Raw shapes don't have _def or _zod properties and aren't schemas themselves + if (typeof schema === 'object') { + // Check if it's actually a ZodRawShapeCompat (not a schema instance) + // by checking if it lacks schema-like internal properties + const asV3 = schema as unknown as ZodV3Internal; + const asV4 = schema as unknown as ZodV4Internal; + + // If it's not a schema instance (no _def or _zod), it might be a raw shape + if (!asV3._def && !asV4._zod) { + // Check if all values are schemas (heuristic to confirm it's a raw shape) + const values = Object.values(schema); + if ( + values.length > 0 && + values.every( + v => + typeof v === 'object' && + v !== null && + ((v as unknown as ZodV3Internal)._def !== undefined || + (v as unknown as ZodV4Internal)._zod !== undefined || + typeof (v as { parse?: unknown }).parse === 'function') + ) + ) { + return objectFromShape(schema as ZodRawShapeCompat); + } + } + } + + // If we get here, it should be an AnySchema (not a raw shape) + // Check if it's already an object schema + if (isZ4Schema(schema as AnySchema)) { + // Check if it's a v4 object + const v4Schema = schema as unknown as ZodV4Internal; + const def = v4Schema._zod?.def; + if (def && (def.type === 'object' || def.shape !== undefined)) { + return schema as AnyObjectSchema; + } + } else { + // Check if it's a v3 object + const v3Schema = schema as unknown as ZodV3Internal; + if (v3Schema.shape !== undefined) { + return schema as AnyObjectSchema; + } + } + + return undefined; +} + +// --- Error message extraction --- +/** + * Safely extracts an error message from a parse result error. + * Zod errors can have different structures, so we handle various cases. + */ +export function getParseErrorMessage(error: unknown): string { + if (error && typeof error === 'object') { + // Try common error structures + if ('message' in error && typeof error.message === 'string') { + return error.message; + } + if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { + const firstIssue = error.issues[0]; + if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) { + return String(firstIssue.message); + } + } + // Fallback: try to stringify the error + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + return String(error); +} + +// --- Schema metadata access --- +/** + * Gets the description from a schema, if available. + * Works with both Zod v3 and v4. + */ +export function getSchemaDescription(schema: AnySchema): string | undefined { + if (isZ4Schema(schema)) { + const v4Schema = schema as unknown as ZodV4Internal; + return v4Schema._zod?.def?.description; + } + const v3Schema = schema as unknown as ZodV3Internal; + // v3 may have description on the schema itself or in _def + return (schema as { description?: string }).description ?? v3Schema._def?.description; +} + +/** + * Checks if a schema is optional. + * Works with both Zod v3 and v4. + */ +export function isSchemaOptional(schema: AnySchema): boolean { + if (isZ4Schema(schema)) { + const v4Schema = schema as unknown as ZodV4Internal; + return v4Schema._zod?.def?.type === 'optional'; + } + const v3Schema = schema as unknown as ZodV3Internal; + // v3 has isOptional() method + if (typeof (schema as { isOptional?: () => boolean }).isOptional === 'function') { + return (schema as { isOptional: () => boolean }).isOptional(); + } + return v3Schema._def?.typeName === 'ZodOptional'; +} + +/** + * Gets the literal value from a schema, if it's a literal schema. + * Works with both Zod v3 and v4. + * Returns undefined if the schema is not a literal or the value cannot be determined. + */ +export function getLiteralValue(schema: AnySchema): unknown { + if (isZ4Schema(schema)) { + const v4Schema = schema as unknown as ZodV4Internal; + const def = v4Schema._zod?.def; + if (def) { + // Try various ways to get the literal value + if (def.value !== undefined) return def.value; + if (Array.isArray(def.values) && def.values.length > 0) { + return def.values[0]; + } + } + } + const v3Schema = schema as unknown as ZodV3Internal; + const def = v3Schema._def; + if (def) { + if (def.value !== undefined) return def.value; + if (Array.isArray(def.values) && def.values.length > 0) { + return def.values[0]; + } + } + // Fallback: check for direct value property (some Zod versions) + const directValue = (schema as { value?: unknown }).value; + if (directValue !== undefined) return directValue; + return undefined; +} diff --git a/packages/shared/src/util/zod-json-schema-compat.ts b/packages/shared/src/util/zod-json-schema-compat.ts new file mode 100644 index 000000000..cde66b177 --- /dev/null +++ b/packages/shared/src/util/zod-json-schema-compat.ts @@ -0,0 +1,68 @@ +// zod-json-schema-compat.ts +// ---------------------------------------------------- +// JSON Schema conversion for both Zod v3 and Zod v4 (Mini) +// v3 uses your vendored converter; v4 uses Mini's toJSONSchema +// ---------------------------------------------------- + +import type * as z3 from 'zod/v3'; +import type * as z4c from 'zod/v4/core'; + +import * as z4mini from 'zod/v4-mini'; + +import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +type JsonSchema = Record; + +// Options accepted by call sites; we map them appropriately +type CommonOpts = { + strictUnions?: boolean; + pipeStrategy?: 'input' | 'output'; + target?: 'jsonSchema7' | 'draft-7' | 'jsonSchema2019-09' | 'draft-2020-12'; +}; + +function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft-2020-12' { + if (!t) return 'draft-7'; + if (t === 'jsonSchema7' || t === 'draft-7') return 'draft-7'; + if (t === 'jsonSchema2019-09' || t === 'draft-2020-12') return 'draft-2020-12'; + return 'draft-7'; // fallback +} + +export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { + if (isZ4Schema(schema)) { + // v4 branch — use Mini's built-in toJSONSchema + return z4mini.toJSONSchema(schema as z4c.$ZodType, { + target: mapMiniTarget(opts?.target), + io: opts?.pipeStrategy ?? 'input' + }) as JsonSchema; + } + + // v3 branch — use vendored converter + return zodToJsonSchema(schema as z3.ZodTypeAny, { + strictUnions: opts?.strictUnions ?? true, + pipeStrategy: opts?.pipeStrategy ?? 'input' + }) as JsonSchema; +} + +export function getMethodLiteral(schema: AnyObjectSchema): string { + const shape = getObjectShape(schema); + const methodSchema = shape?.method as AnySchema | undefined; + if (!methodSchema) { + throw new Error('Schema is missing a method literal'); + } + + const value = getLiteralValue(methodSchema); + if (typeof value !== 'string') { + throw new Error('Schema method literal must be a string'); + } + + return value; +} + +export function parseWithCompat(schema: AnySchema, data: unknown): unknown { + const result = safeParse(schema, data); + if (!result.success) { + throw result.error; + } + return result.data; +} diff --git a/packages/shared/test/.gitkeep b/packages/shared/test/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 000000000..30f53d66c --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + } +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -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 000000000..be2b7472e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3420 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +overrides: + strip-ansi: 6.0.1 + +importers: + + .: + dependencies: + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + content-type: + specifier: ^1.0.5 + version: 1.0.5 + cors: + specifier: ^2.8.5 + version: 2.8.5 + 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 + express: + specifier: ^5.0.1 + version: 5.1.0 + express-rate-limit: + specifier: ^7.5.0 + version: 7.5.1(express@5.1.0) + jose: + specifier: ^6.1.1 + version: 6.1.3 + json-schema-typed: + specifier: ^8.0.2 + version: 8.0.2 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + raw-body: + specifier: ^3.0.0 + version: 3.0.1 + zod: + specifier: ^3.25 || ^4.0 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.25.0 + version: 3.25.0(zod@3.25.76) + devDependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@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.0 + version: 5.0.5 + '@types/express-serve-static-core': + specifier: ^5.1.0 + version: 5.1.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20251103.1 + version: 7.0.0-dev.20251108.1 + eslint: + specifier: ^9.8.0 + version: 9.39.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: ^7.0.0 + version: 7.1.4 + tsx: + specifier: ^4.16.5 + version: 4.20.6 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.1 + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: ^4.0.8 + version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + ws: + specifier: ^8.18.0 + version: 8.18.3 + + common/tsconfig: + dependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + + common/vitest-config: + dependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + devDependencies: + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + + packages/client: + dependencies: + '@modelcontextprotocol/shared': + specifier: workspace:^ + version: link:../shared + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + content-type: + specifier: ^1.0.5 + version: 1.0.5 + cors: + specifier: ^2.8.5 + version: 2.8.5 + 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 + express: + specifier: ^5.0.1 + version: 5.1.0 + express-rate-limit: + specifier: ^7.5.0 + version: 7.5.1(express@5.1.0) + jose: + specifier: ^6.1.1 + version: 6.1.3 + json-schema-typed: + specifier: ^8.0.2 + version: 8.0.2 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + raw-body: + specifier: ^3.0.0 + version: 3.0.1 + zod: + specifier: ^3.25 || ^4.0 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.25.0 + version: 3.25.0(zod@3.25.76) + devDependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@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.0 + version: 5.0.5 + '@types/express-serve-static-core': + specifier: ^5.1.0 + version: 5.1.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20251103.1 + version: 7.0.0-dev.20251108.1 + eslint: + specifier: ^9.8.0 + version: 9.39.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: ^7.0.0 + version: 7.1.4 + tsx: + specifier: ^4.16.5 + version: 4.20.6 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.1 + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: ^4.0.8 + version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + ws: + specifier: ^8.18.0 + version: 8.18.3 + + packages/examples: + dependencies: + '@modelcontextprotocol/sdk-client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/sdk-server': + specifier: workspace:^ + version: link:../server + '@modelcontextprotocol/shared': + specifier: workspace:^ + version: link:../shared + devDependencies: + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + + packages/server: + dependencies: + '@modelcontextprotocol/shared': + specifier: workspace:^ + version: link:../shared + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + content-type: + specifier: ^1.0.5 + version: 1.0.5 + cors: + specifier: ^2.8.5 + version: 2.8.5 + 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 + express: + specifier: ^5.0.1 + version: 5.1.0 + express-rate-limit: + specifier: ^7.5.0 + version: 7.5.1(express@5.1.0) + jose: + specifier: ^6.1.1 + version: 6.1.3 + json-schema-typed: + specifier: ^8.0.2 + version: 8.0.2 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + raw-body: + specifier: ^3.0.0 + version: 3.0.1 + zod: + specifier: ^3.25 || ^4.0 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.25.0 + version: 3.25.0(zod@3.25.76) + devDependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@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.0 + version: 5.0.5 + '@types/express-serve-static-core': + specifier: ^5.1.0 + version: 5.1.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20251103.1 + version: 7.0.0-dev.20251108.1 + eslint: + specifier: ^9.8.0 + version: 9.39.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: ^7.0.0 + version: 7.1.4 + tsx: + specifier: ^4.16.5 + version: 4.20.6 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.1 + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: ^4.0.8 + version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + ws: + specifier: ^8.18.0 + version: 8.18.3 + + packages/shared: + dependencies: + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + content-type: + specifier: ^1.0.5 + version: 1.0.5 + cors: + specifier: ^2.8.5 + version: 2.8.5 + 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 + express: + specifier: ^5.0.1 + version: 5.1.0 + express-rate-limit: + specifier: ^7.5.0 + version: 7.5.1(express@5.1.0) + jose: + specifier: ^6.1.1 + version: 6.1.3 + json-schema-typed: + specifier: ^8.0.2 + version: 8.0.2 + pkce-challenge: + specifier: ^5.0.0 + version: 5.0.0 + raw-body: + specifier: ^3.0.0 + version: 3.0.1 + zod: + specifier: ^3.25 || ^4.0 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.25.0 + version: 3.25.0(zod@3.25.76) + devDependencies: + '@cfworker/json-schema': + specifier: ^4.1.1 + version: 4.1.1 + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + '@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.0 + version: 5.0.5 + '@types/express-serve-static-core': + specifier: ^5.1.0 + version: 5.1.0 + '@types/node': + specifier: ^22.12.0 + version: 22.19.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + '@types/ws': + specifier: ^8.5.12 + version: 8.18.1 + '@typescript/native-preview': + specifier: ^7.0.0-dev.20251103.1 + version: 7.0.0-dev.20251108.1 + eslint: + specifier: ^9.8.0 + version: 9.39.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 + supertest: + specifier: ^7.0.0 + version: 7.1.4 + tsx: + specifier: ^4.16.5 + version: 4.20.6 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.1 + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + vitest: + specifier: ^4.0.8 + version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + ws: + specifier: ^8.18.0 + version: 8.18.3 + +packages: + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + 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.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + 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.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + 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} + + '@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'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@rollup/rollup-android-arm-eabi@4.53.2': + resolution: {integrity: sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.2': + resolution: {integrity: sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.2': + resolution: {integrity: sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.2': + resolution: {integrity: sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.2': + resolution: {integrity: sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.2': + resolution: {integrity: sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.2': + resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.2': + resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.2': + resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.2': + resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.2': + resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.2': + resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.2': + resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.2': + resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.2': + resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.2': + resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.2': + resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.2': + resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.2': + resolution: {integrity: sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.2': + resolution: {integrity: sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.2': + resolution: {integrity: sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.2': + resolution: {integrity: sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + + '@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.0': + resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==} + + '@types/express@5.0.5': + resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==} + + '@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/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@22.19.0': + resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@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/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/eslint-plugin@8.49.0': + resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.49.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.49.0': + resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.49.0': + resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.49.0': + resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.49.0': + resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.49.0': + resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.49.0': + resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.49.0': + resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.49.0': + resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.49.0': + resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-zdD59CWlvJum9hu7Rrb7xntGfkeTlki7Pql/s+Ls0sNSEGimjuJmU88ookUt2UMPLZ9S+RyGfjNbS7duhKuCIw==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-nxs1vOm6jkwZoA56d6E2pllhYbxK2xNtspG2Yx9kswdp0aqh2jcja1EjMilHv4tnvNixRQvRWhoOz/BgWrxTig==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-Er0P4Dt6fBM6MZidGBKGZs5PS2QHunRPNeIKCATJRcBMn8ymSBdi/IJK/wYVU62HW8CLb1Dt3FbueW5UptSAKA==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-+1UuKod2SJZODnArwHViJEhZxgM1aHK5KCGlHJU61htq3jgNtRfouw4UEJRPTALCnGLun8ZpYuMsJUMeDFFIUQ==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-3ijz+Uo8unENgq22nlyThf1JqhLVOwN881gomoAykALmspXX13aQwnDtbscnQRr1iQqMIrEyyRMwxfVevQkl9w==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-Aeyrj7sdc6GBnLIw9o5dQPzEqrfsJodDYsi7RePm0QLvGVyxOeZVd9CcfdXqPKnD2goEJNOzFoTRyvvi7DjNUg==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-rgfq7AZ7IFfE5EyDuHdpDEPUH3LEesyOVuIHE6YWOSqGOFnlzYbOf37VUYxQTIxFrR7IopgvM4pSDR3KeDcMjQ==} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20251108.1': + resolution: {integrity: sha512-v1SNmHbuTYMEIAAJZ5OgKY5kMIgDnS/aVTsP9FdR9FgqyZqgUbA2eHOjjMQHVw/XBLS5ZA32kkGt7cH8RzMlOA==} + hasBin: true + + '@vitest/expect@4.0.9': + resolution: {integrity: sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==} + + '@vitest/mocker@4.0.9': + resolution: {integrity: sha512-PUyaowQFHW+9FKb4dsvvBM4o025rWMlEDXdWRxIOilGaHREYTi5Q2Rt9VCgXgPy/hHZu1LeuXtrA/GdzOatP2g==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.9': + resolution: {integrity: sha512-Hor0IBTwEi/uZqB7pvGepyElaM8J75pYjrrqbC8ZYMB9/4n5QA63KC15xhT+sqHpdGWfdnPo96E8lQUxs2YzSQ==} + + '@vitest/runner@4.0.9': + resolution: {integrity: sha512-aF77tsXdEvIJRkj9uJZnHtovsVIx22Ambft9HudC+XuG/on1NY/bf5dlDti1N35eJT+QZLb4RF/5dTIG18s98w==} + + '@vitest/snapshot@4.0.9': + resolution: {integrity: sha512-r1qR4oYstPbnOjg0Vgd3E8ADJbi4ditCzqr+Z9foUrRhIy778BleNyZMeAJ2EjV+r4ASAaDsdciC9ryMy8xMMg==} + + '@vitest/spy@4.0.9': + resolution: {integrity: sha512-J9Ttsq0hDXmxmT8CUOWUr1cqqAj2FJRGTdyEjSR+NjoOGKEqkEWj+09yC0HhI8t1W6t4Ctqawl1onHgipJve1A==} + + '@vitest/utils@4.0.9': + resolution: {integrity: sha512-cEol6ygTzY4rUPvNZM19sDf7zGa35IYTm9wfzkHoT/f5jX10IOY7QleWSOh5T0e3I3WVozwK5Asom79qW8DiuQ==} + + 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.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + 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.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + 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'} + + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + 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'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + 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'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + 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'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + 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==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + 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@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + 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'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + 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-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-n@17.23.1: + resolution: {integrity: sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.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@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + 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} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + 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'} + + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + 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==} + + 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'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.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.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + 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'} + + 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==} + + 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-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + 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'} + + 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-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + 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'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + 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'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + 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==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + 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==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + 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'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.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.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + 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 + + 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'} + + 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'} + + 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==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + 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-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-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.1: + resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + engines: {node: '>= 0.10'} + + 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-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.53.2: + resolution: {integrity: sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + 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==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + superagent@10.2.3: + resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==} + engines: {node: '>=14.18.0'} + + supertest@7.1.4: + resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} + engines: {node: '>=14.18.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + 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' + + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + + 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'} + + typescript-eslint@8.49.0: + resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.2.2: + resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} + 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.0.9: + resolution: {integrity: sha512-E0Ja2AX4th+CG33yAFRC+d1wFx2pzU5r6HtG6LiPSE04flaE0qB6YyjSw9ZcpJAtVPfsvZGtJlKWZpuW7EHRxg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.9 + '@vitest/browser-preview': 4.0.9 + '@vitest/browser-webdriverio': 4.0.9 + '@vitest/ui': 4.0.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + 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 + + 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'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + 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 + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@cfworker/json-schema@4.1.1': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)': + dependencies: + eslint: 9.39.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + 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.1': + dependencies: + ajv: 6.12.6 + 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.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.1': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@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': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@rollup/rollup-android-arm-eabi@4.53.2': + optional: true + + '@rollup/rollup-android-arm64@4.53.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.2': + optional: true + + '@rollup/rollup-darwin-x64@4.53.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.2': + optional: true + + '@standard-schema/spec@1.0.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.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': 22.19.0 + + '@types/content-type@1.1.9': {} + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.0 + + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 22.19.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/eventsource@1.1.15': {} + + '@types/express-serve-static-core@5.1.0': + dependencies: + '@types/node': 22.19.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.5': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/node@22.19.0': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.19.0 + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.0 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.0 + '@types/send': 0.17.6 + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.0 + form-data: 4.0.4 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.0 + + '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 + eslint: 9.39.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.49.0 + debug: 4.4.3 + eslint: 9.39.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.49.0': + dependencies: + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 + + '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.49.0': {} + + '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/visitor-keys': 8.49.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.49.0(eslint@9.39.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@typescript-eslint/scope-manager': 8.49.0 + '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + eslint: 9.39.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.49.0': + dependencies: + '@typescript-eslint/types': 8.49.0 + eslint-visitor-keys: 4.2.1 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20251108.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20251108.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20251108.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20251108.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20251108.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20251108.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20251108.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20251108.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20251108.1 + + '@vitest/expect@4.0.9': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.9 + '@vitest/utils': 4.0.9 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.9(vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6))': + dependencies: + '@vitest/spy': 4.0.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@22.19.0)(tsx@4.20.6) + + '@vitest/pretty-format@4.0.9': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.9': + dependencies: + '@vitest/utils': 4.0.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.9': + dependencies: + '@vitest/pretty-format': 4.0.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.9': {} + + '@vitest/utils@4.0.9': + dependencies: + '@vitest/pretty-format': 4.0.9 + tinyrainbow: 3.0.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@6.12.6: + 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.17.1: + 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-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + balanced-match@1.0.2: {} + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + chai@6.2.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + 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 + + component-emitter@1.3.1: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + cors@2.8.5: + 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 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + 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: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.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 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@9.39.1): + dependencies: + eslint: 9.39.1 + semver: 7.7.3 + + eslint-config-prettier@10.1.8(eslint@9.39.1): + dependencies: + eslint: 9.39.1 + + eslint-plugin-es-x@7.8.0(eslint@9.39.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@eslint-community/regexpp': 4.12.2 + eslint: 9.39.1 + eslint-compat-utils: 0.5.1(eslint@9.39.1) + + eslint-plugin-n@17.23.1(eslint@9.39.1)(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + enhanced-resolve: 5.18.3 + eslint: 9.39.1 + eslint-plugin-es-x: 7.8.0(eslint@9.39.1) + get-tsconfig: 4.13.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.7.3 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + 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@9.39.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.1 + '@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.12.6 + 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.6.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.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.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 + + expect-type@1.2.2: {} + + express-rate-limit@7.5.1(express@5.1.0): + dependencies: + express: 5.1.0 + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + finalhandler@2.1.0: + 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-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + form-data@4.0.4: + 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: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + 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-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + 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 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.1.3: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + 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: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.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.1: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + 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 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.6.2: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.1: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.53.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.2 + '@rollup/rollup-android-arm64': 4.53.2 + '@rollup/rollup-darwin-arm64': 4.53.2 + '@rollup/rollup-darwin-x64': 4.53.2 + '@rollup/rollup-freebsd-arm64': 4.53.2 + '@rollup/rollup-freebsd-x64': 4.53.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.2 + '@rollup/rollup-linux-arm-musleabihf': 4.53.2 + '@rollup/rollup-linux-arm64-gnu': 4.53.2 + '@rollup/rollup-linux-arm64-musl': 4.53.2 + '@rollup/rollup-linux-loong64-gnu': 4.53.2 + '@rollup/rollup-linux-ppc64-gnu': 4.53.2 + '@rollup/rollup-linux-riscv64-gnu': 4.53.2 + '@rollup/rollup-linux-riscv64-musl': 4.53.2 + '@rollup/rollup-linux-s390x-gnu': 4.53.2 + '@rollup/rollup-linux-x64-gnu': 4.53.2 + '@rollup/rollup-linux-x64-musl': 4.53.2 + '@rollup/rollup-openharmony-arm64': 4.53.2 + '@rollup/rollup-win32-arm64-msvc': 4.53.2 + '@rollup/rollup-win32-ia32-msvc': 4.53.2 + '@rollup/rollup-win32-x64-gnu': 4.53.2 + '@rollup/rollup-win32-x64-msvc': 4.53.2 + fsevents: 2.3.3 + + 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.3.0 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.3: {} + + send@1.2.0: + 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.0 + mime-types: 3.0.1 + 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.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + 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: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + superagent@10.2.3: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.4 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + transitivePeerDependencies: + - supports-color + + supertest@7.1.4: + dependencies: + methods: 1.1.2 + superagent: 10.2.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tapable@2.3.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + toidentifier@1.0.1: {} + + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.3 + typescript: 5.9.3 + + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + 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.1 + + typescript-eslint@8.49.0(eslint@9.39.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + eslint: 9.39.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vary@1.1.2: {} + + vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.0 + fsevents: 2.3.3 + tsx: 4.20.6 + + vitest@4.0.9(@types/node@22.19.0)(tsx@4.20.6): + dependencies: + '@vitest/expect': 4.0.9 + '@vitest/mocker': 4.0.9(vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6)) + '@vitest/pretty-format': 4.0.9 + '@vitest/runner': 4.0.9 + '@vitest/snapshot': 4.0.9 + '@vitest/spy': 4.0.9 + '@vitest/utils': 4.0.9 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@22.19.0)(tsx@4.20.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + 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: {} + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.0(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..a6a17ecd8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - packages/**/* + - common/**/* + +catalog: + typescript: ^5.9.3 +# cleanupUnusedCatalogs: true # Breaks Dockerfile builds. Commented out until Dockerfile is updated to skip this step on the first pnpm install. + +enableGlobalVirtualStore: false + +linkWorkspacePackages: deep From cfa06152f5183557be0af6df8baa703db705b3e6 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 10:31:16 +0200 Subject: [PATCH 02/22] barrel exports, imports --- packages/client/src/client/auth-extensions.ts | 2 +- packages/client/src/client/auth.ts | 12 +- .../client/src/client/{index.ts => client.ts} | 16 +- packages/client/src/client/middleware.ts | 2 +- packages/client/src/client/sse.ts | 4 +- packages/client/src/client/stdio.ts | 6 +- packages/client/src/client/streamableHttp.ts | 4 +- packages/client/src/client/websocket.ts | 4 +- packages/client/src/experimental/index.ts | 2 +- .../experimental/tasks/stores/in-memory.ts | 295 ------------------ packages/client/src/index.ts | 14 + packages/examples/package.json | 1 - .../src/client/elicitationUrlExample.ts | 12 +- .../src/client/multipleClientsParallel.ts | 6 +- .../src/client/parallelToolCallsClient.ts | 6 +- .../src/client/simpleClientCredentials.ts | 8 +- .../examples/src/client/simpleOAuthClient.ts | 10 +- .../src/client/simpleOAuthClientProvider.ts | 4 +- .../src/client/simpleStreamableHttp.ts | 8 +- .../src/client/simpleTaskInteractiveClient.ts | 6 +- .../examples/src/client/ssePollingClient.ts | 6 +- .../streamableHttpWithSseFallbackClient.ts | 8 +- .../src/server/demoInMemoryOAuthProvider.ts | 14 +- .../src/server/elicitationFormExample.ts | 8 +- .../src/server/elicitationUrlExample.ts | 16 +- .../src/server/jsonResponseStreamableHttp.ts | 8 +- .../src/server/mcpServerOutputSchema.ts | 4 +- .../examples/src/server/simpleSseServer.ts | 8 +- .../server/simpleStatelessStreamableHttp.ts | 8 +- .../src/server/simpleStreamableHttp.ts | 18 +- .../src/server/simpleTaskInteractive.ts | 12 +- .../sseAndStreamableHttpCompatibleServer.ts | 10 +- .../examples/src/server/ssePollingExample.ts | 8 +- .../standaloneSseWithGetStreamableHttp.ts | 8 +- .../src/server/toolWithSampleServer.ts | 4 +- packages/examples/tsconfig.json | 2 +- .../server/src/experimental/tasks/index.ts | 3 - .../src/experimental/tasks/interfaces.ts | 235 +------------- .../src/experimental/tasks/mcp-server.ts | 3 +- packages/server/src/index.ts | 38 +-- packages/server/src/server/auth/clients.ts | 2 +- .../src/server/auth/handlers/authorize.ts | 2 +- .../src/server/auth/handlers/metadata.ts | 2 +- .../src/server/auth/handlers/register.ts | 4 +- .../server/src/server/auth/handlers/revoke.ts | 4 +- .../server/src/server/auth/handlers/token.ts | 2 +- packages/server/src/server/auth/index.ts | 15 + .../server/auth/middleware/allowedMethods.ts | 2 +- .../src/server/auth/middleware/bearerAuth.ts | 4 +- .../src/server/auth/middleware/clientAuth.ts | 4 +- packages/server/src/server/auth/provider.ts | 4 +- .../server/auth/providers/proxyProvider.ts | 8 +- packages/server/src/server/auth/router.ts | 2 +- packages/server/src/server/completable.ts | 2 +- packages/server/src/server/server.ts | 6 +- packages/server/src/server/sse.ts | 6 +- packages/server/src/server/stdio.ts | 6 +- packages/server/src/server/streamableHttp.ts | 6 +- .../src/server => shared/src}/auth/errors.ts | 2 +- packages/shared/src/experimental/index.ts | 2 + .../src/experimental/tasks/helpers.ts | 0 .../src/experimental/tasks/interfaces.ts | 243 +++++++++++++++ packages/shared/src/index.ts | 40 ++- .../src/validation/ajv-provider.ts | 0 .../src/validation/cfworker-provider.ts | 0 .../src/validation/types.ts | 0 pnpm-lock.yaml | 3 - pnpm-workspace.yaml | 1 - 68 files changed, 483 insertions(+), 732 deletions(-) rename packages/client/src/client/{index.ts => client.ts} (98%) delete mode 100644 packages/client/src/experimental/tasks/stores/in-memory.ts create mode 100644 packages/server/src/server/auth/index.ts rename packages/{server/src/server => shared/src}/auth/errors.ts (99%) create mode 100644 packages/shared/src/experimental/index.ts rename packages/{server => shared}/src/experimental/tasks/helpers.ts (100%) create mode 100644 packages/shared/src/experimental/tasks/interfaces.ts rename packages/{server => shared}/src/validation/ajv-provider.ts (100%) rename packages/{server => shared}/src/validation/cfworker-provider.ts (100%) rename packages/{server => shared}/src/validation/types.ts (100%) diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts index f3908d2c2..56ffd3929 100644 --- a/packages/client/src/client/auth-extensions.ts +++ b/packages/client/src/client/auth-extensions.ts @@ -6,7 +6,7 @@ */ import type { JWK } from 'jose'; -import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/shared'; import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4c82b5114..d8400a66e 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,5 +1,5 @@ import pkceChallenge from 'pkce-challenge'; -import { LATEST_PROTOCOL_VERSION } from '../types.js'; +import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/shared'; import { OAuthClientMetadata, OAuthClientInformation, @@ -11,14 +11,14 @@ import { OAuthErrorResponseSchema, AuthorizationServerMetadata, OpenIdProviderDiscoveryMetadataSchema -} from '../shared/auth.js'; +} from '@modelcontextprotocol/shared'; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema -} from '../shared/auth.js'; -import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; +} from '@modelcontextprotocol/shared'; +import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/shared'; import { InvalidClientError, InvalidClientMetadataError, @@ -27,8 +27,8 @@ import { OAuthError, ServerError, UnauthorizedClientError -} from '../server/auth/errors.js'; -import { FetchLike } from '../shared/transport.js'; +} from '@modelcontextprotocol/shared'; +import { FetchLike } from '@modelcontextprotocol/shared'; /** * Function type for adding client authentication to token requests. diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/client.ts similarity index 98% rename from packages/client/src/client/index.ts rename to packages/client/src/client/client.ts index 28c0e6253..41fd728ce 100644 --- a/packages/client/src/client/index.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,5 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; -import type { Transport } from '../shared/transport.js'; +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/shared'; +import type { Transport } from '@modelcontextprotocol/shared'; import { type CallToolRequest, @@ -49,9 +49,9 @@ import { type Request, type Notification, type Result -} from '../types.js'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; +} from '@modelcontextprotocol/shared'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/shared'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/shared'; import { AnyObjectSchema, SchemaOutput, @@ -60,10 +60,10 @@ import { safeParse, type ZodV3Internal, type ZodV4Internal -} from '../server/zod-compat.js'; -import type { RequestHandlerExtra } from '../shared/protocol.js'; +} from '@modelcontextprotocol/shared'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/shared'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.js'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/shared'; /** * Elicitation default application helper. Applies defaults to the data based on the schema. diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index c8f7fdd3d..e0847ca7a 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,5 +1,5 @@ import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { FetchLike } from '../shared/transport.js'; +import { FetchLike } from '@modelcontextprotocol/shared'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f0e91ff25..9d177900e 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,6 +1,6 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/shared'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; export class SseError extends Error { diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index e488dcd24..dbccebf20 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -2,9 +2,9 @@ import { ChildProcess, IOType } from 'node:child_process'; import spawn from 'cross-spawn'; import process from 'node:process'; import { Stream, PassThrough } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage } from '../types.js'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/shared'; +import { Transport } from '@modelcontextprotocol/shared'; +import { JSONRPCMessage } from '@modelcontextprotocol/shared'; export type StdioServerParameters = { /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 736587973..8943270c8 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,5 +1,5 @@ -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/shared'; +import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index aed766caf..be2e2c2b6 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,5 +1,5 @@ -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { Transport } from '@modelcontextprotocol/shared'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; const SUBPROTOCOL = 'mcp'; diff --git a/packages/client/src/experimental/index.ts b/packages/client/src/experimental/index.ts index 55dd44ed0..926369f99 100644 --- a/packages/client/src/experimental/index.ts +++ b/packages/client/src/experimental/index.ts @@ -10,4 +10,4 @@ * @experimental */ -export * from './tasks/index.js'; +export * from './tasks/client.js'; diff --git a/packages/client/src/experimental/tasks/stores/in-memory.ts b/packages/client/src/experimental/tasks/stores/in-memory.ts deleted file mode 100644 index aff3ad910..000000000 --- a/packages/client/src/experimental/tasks/stores/in-memory.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * In-memory implementations of TaskStore and TaskMessageQueue. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import { Task, RequestId, Result, Request } from '../../../types.js'; -import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; -import { randomBytes } from 'node:crypto'; - -interface StoredTask { - task: Task; - request: Request; - requestId: RequestId; - result?: Result; -} - -/** - * A simple in-memory implementation of TaskStore for demonstration purposes. - * - * This implementation stores all tasks in memory and provides automatic cleanup - * based on the ttl duration specified in the task creation parameters. - * - * Note: This is not suitable for production use as all data is lost on restart. - * For production, consider implementing TaskStore with a database or distributed cache. - * - * @experimental - */ -export class InMemoryTaskStore implements TaskStore { - private tasks = new Map(); - private cleanupTimers = new Map>(); - - /** - * Generates a unique task ID. - * Uses 16 bytes of random data encoded as hex (32 characters). - */ - private generateTaskId(): string { - return randomBytes(16).toString('hex'); - } - - 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 - }); - - // 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; - } - - async getTask(taskId: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - return stored ? { ...stored.task } : null; - } - - async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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); - } - } - - async getTaskResult(taskId: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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; - } - - async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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); - } - } - - async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { - const PAGE_SIZE = 10; - const allTaskIds = Array.from(this.tasks.keys()); - - let startIndex = 0; - if (cursor) { - const cursorIndex = allTaskIds.indexOf(cursor); - if (cursorIndex >= 0) { - startIndex = cursorIndex + 1; - } else { - // Invalid cursor - throw error - throw new Error(`Invalid cursor: ${cursor}`); - } - } - - const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE); - const tasks = pageTaskIds.map(taskId => { - const stored = this.tasks.get(taskId)!; - return { ...stored.task }; - }); - - const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 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 Array.from(this.tasks.values()).map(stored => ({ ...stored.task })); - } -} - -/** - * A simple in-memory implementation of TaskMessageQueue for demonstration purposes. - * - * This implementation stores messages in memory, organized by task ID and optional session ID. - * Messages are stored in FIFO queues per task. - * - * Note: This is not suitable for production use in distributed systems. - * For production, consider implementing TaskMessageQueue with Redis or other distributed queues. - * - * @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/client/src/index.ts b/packages/client/src/index.ts index e69de29bb..5bd2e8360 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -0,0 +1,14 @@ +export * from './client/client.js'; +export * from './client/auth-extensions.js'; +export * from './client/auth.js'; +export * from './client/middleware.js'; +export * from './client/sse.js'; +export * from './client/stdio.js'; +export * from './client/streamableHttp.js'; +export * from './client/websocket.js'; + +// experimental exports +export * from './experimental/index.js'; + +// re-export shared types +export * from '@modelcontextprotocol/shared'; \ No newline at end of file diff --git a/packages/examples/package.json b/packages/examples/package.json index 2733b2110..ba85817fd 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -36,7 +36,6 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/shared": "workspace:^", "@modelcontextprotocol/sdk-server": "workspace:^", "@modelcontextprotocol/sdk-client": "workspace:^" }, diff --git a/packages/examples/src/client/elicitationUrlExample.ts b/packages/examples/src/client/elicitationUrlExample.ts index b57927e3f..3af2d8d33 100644 --- a/packages/examples/src/client/elicitationUrlExample.ts +++ b/packages/examples/src/client/elicitationUrlExample.ts @@ -5,8 +5,8 @@ // URL elicitation allows servers to prompt the end-user to open a URL in their browser // to collect sensitive information. -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { createInterface } from 'node:readline'; import { ListToolsRequest, @@ -22,12 +22,12 @@ import { ErrorCode, UrlElicitationRequiredError, ElicitationCompleteNotificationSchema -} from '../../types.js'; -import { getDisplayName } from '../../shared/metadataUtils.js'; -import { OAuthClientMetadata } from '../../shared/auth.js'; +} from '@modelcontextprotocol/sdk-client'; +import { getDisplayName } from '@modelcontextprotocol/sdk-client'; +import { OAuthClientMetadata } from '@modelcontextprotocol/sdk-client'; import { exec } from 'node:child_process'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -import { UnauthorizedError } from '../../client/auth.js'; +import { UnauthorizedError } from '@modelcontextprotocol/sdk-client'; import { createServer } from 'node:http'; // Set up OAuth (required for this example) diff --git a/packages/examples/src/client/multipleClientsParallel.ts b/packages/examples/src/client/multipleClientsParallel.ts index 492235cdd..d4a0ec1f7 100644 --- a/packages/examples/src/client/multipleClientsParallel.ts +++ b/packages/examples/src/client/multipleClientsParallel.ts @@ -1,6 +1,6 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '../../types.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '@modelcontextprotocol/sdk-client'; /** * Multiple Clients MCP Example diff --git a/packages/examples/src/client/parallelToolCallsClient.ts b/packages/examples/src/client/parallelToolCallsClient.ts index 2ad249de7..1ce6bf2cf 100644 --- a/packages/examples/src/client/parallelToolCallsClient.ts +++ b/packages/examples/src/client/parallelToolCallsClient.ts @@ -1,12 +1,12 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { ListToolsRequest, ListToolsResultSchema, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult -} from '../../types.js'; +} from '@modelcontextprotocol/sdk-client'; /** * Parallel Tool Calls MCP Client diff --git a/packages/examples/src/client/simpleClientCredentials.ts b/packages/examples/src/client/simpleClientCredentials.ts index 7defcc41f..f22801a75 100644 --- a/packages/examples/src/client/simpleClientCredentials.ts +++ b/packages/examples/src/client/simpleClientCredentials.ts @@ -18,10 +18,10 @@ * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) */ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../client/auth-extensions.js'; -import { OAuthClientProvider } from '../../client/auth.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk-client'; +import { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; diff --git a/packages/examples/src/client/simpleOAuthClient.ts b/packages/examples/src/client/simpleOAuthClient.ts index 8071e61ac..abcea852b 100644 --- a/packages/examples/src/client/simpleOAuthClient.ts +++ b/packages/examples/src/client/simpleOAuthClient.ts @@ -4,11 +4,11 @@ import { createServer } from 'node:http'; import { createInterface } from 'node:readline'; import { URL } from 'node:url'; import { exec } from 'node:child_process'; -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { OAuthClientMetadata } from '../../shared/auth.js'; -import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '../../types.js'; -import { UnauthorizedError } from '../../client/auth.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { OAuthClientMetadata } from '@modelcontextprotocol/sdk-client'; +import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk-client'; +import { UnauthorizedError } from '@modelcontextprotocol/sdk-client'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration diff --git a/packages/examples/src/client/simpleOAuthClientProvider.ts b/packages/examples/src/client/simpleOAuthClientProvider.ts index 3f1932c3e..4304b09e9 100644 --- a/packages/examples/src/client/simpleOAuthClientProvider.ts +++ b/packages/examples/src/client/simpleOAuthClientProvider.ts @@ -1,5 +1,5 @@ -import { OAuthClientProvider } from '../../client/auth.js'; -import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; +import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-client'; /** * In-memory OAuth client provider for demonstration purposes diff --git a/packages/examples/src/client/simpleStreamableHttp.ts b/packages/examples/src/client/simpleStreamableHttp.ts index 21ab4f556..1c4df44da 100644 --- a/packages/examples/src/client/simpleStreamableHttp.ts +++ b/packages/examples/src/client/simpleStreamableHttp.ts @@ -1,5 +1,5 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { createInterface } from 'node:readline'; import { ListToolsRequest, @@ -21,8 +21,8 @@ import { RELATED_TASK_META_KEY, ErrorCode, McpError -} from '../../types.js'; -import { getDisplayName } from '../../shared/metadataUtils.js'; +} from '@modelcontextprotocol/sdk-client'; +import { getDisplayName } from '@modelcontextprotocol/sdk-client'; import { Ajv } from 'ajv'; // Create readline interface for user input diff --git a/packages/examples/src/client/simpleTaskInteractiveClient.ts b/packages/examples/src/client/simpleTaskInteractiveClient.ts index 06ed0ead1..679fd69b8 100644 --- a/packages/examples/src/client/simpleTaskInteractiveClient.ts +++ b/packages/examples/src/client/simpleTaskInteractiveClient.ts @@ -7,8 +7,8 @@ * - Using task-based tool execution with streaming */ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { createInterface } from 'node:readline'; import { CallToolResultSchema, @@ -19,7 +19,7 @@ import { CreateMessageResult, ErrorCode, McpError -} from '../../types.js'; +} from '@modelcontextprotocol/sdk-client'; // Create readline interface for user input const readline = createInterface({ diff --git a/packages/examples/src/client/ssePollingClient.ts b/packages/examples/src/client/ssePollingClient.ts index ac7bba37d..a9c3cfdfb 100644 --- a/packages/examples/src/client/ssePollingClient.ts +++ b/packages/examples/src/client/ssePollingClient.ts @@ -12,9 +12,9 @@ * Run with: npx tsx src/examples/client/ssePollingClient.ts * Requires: ssePollingExample.ts server running on port 3001 */ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../types.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { CallToolResultSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-client'; const SERVER_URL = 'http://localhost:3001/mcp'; diff --git a/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts b/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts index 657f48953..c2328f911 100644 --- a/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts +++ b/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts @@ -1,13 +1,13 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { SSEClientTransport } from '../../client/sse.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk-client'; import { ListToolsRequest, ListToolsResultSchema, CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema -} from '../../types.js'; +} from '@modelcontextprotocol/sdk-client'; /** * Simplified Backwards Compatible MCP Client diff --git a/packages/examples/src/server/demoInMemoryOAuthProvider.ts b/packages/examples/src/server/demoInMemoryOAuthProvider.ts index 1abc040ce..507443d58 100644 --- a/packages/examples/src/server/demoInMemoryOAuthProvider.ts +++ b/packages/examples/src/server/demoInMemoryOAuthProvider.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto'; -import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; -import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; +import { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk-server'; +import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk-server'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-server'; import express, { Request, Response } from 'express'; -import { AuthInfo } from '../../server/auth/types.js'; -import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; -import { InvalidRequestError } from '../../server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-server'; +import { createOAuthMetadata, mcpAuthRouter } from '@modelcontextprotocol/sdk-server'; +import { resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk-server'; +import { InvalidRequestError } from '@modelcontextprotocol/sdk-server'; export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { private clients = new Map(); diff --git a/packages/examples/src/server/elicitationFormExample.ts b/packages/examples/src/server/elicitationFormExample.ts index 6c0800949..ee553ac49 100644 --- a/packages/examples/src/server/elicitationFormExample.ts +++ b/packages/examples/src/server/elicitationFormExample.ts @@ -9,10 +9,10 @@ import { randomUUID } from 'node:crypto'; import { type Request, type Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { isInitializeRequest } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed diff --git a/packages/examples/src/server/elicitationUrlExample.ts b/packages/examples/src/server/elicitationUrlExample.ts index 5ddecc4e1..f68da60f0 100644 --- a/packages/examples/src/server/elicitationUrlExample.ts +++ b/packages/examples/src/server/elicitationUrlExample.ts @@ -10,16 +10,16 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; -import { McpServer } from '../../server/mcp.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; -import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '../../types.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk-server'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk-server'; +import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; -import { OAuthMetadata } from '../../shared/auth.js'; -import { checkResourceAllowed } from '../../shared/auth-utils.js'; +import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; +import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; import cors from 'cors'; diff --git a/packages/examples/src/server/jsonResponseStreamableHttp.ts b/packages/examples/src/server/jsonResponseStreamableHttp.ts index 224955c46..8f1d0c7c0 100644 --- a/packages/examples/src/server/jsonResponseStreamableHttp.ts +++ b/packages/examples/src/server/jsonResponseStreamableHttp.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { CallToolResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create an MCP server with implementation details const getServer = () => { diff --git a/packages/examples/src/server/mcpServerOutputSchema.ts b/packages/examples/src/server/mcpServerOutputSchema.ts index 7ef9f6227..e9dae7e1a 100644 --- a/packages/examples/src/server/mcpServerOutputSchema.ts +++ b/packages/examples/src/server/mcpServerOutputSchema.ts @@ -4,8 +4,8 @@ * This demonstrates how to easily create tools with structured output */ -import { McpServer } from '../../server/mcp.js'; -import { StdioServerTransport } from '../../server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; const server = new McpServer({ diff --git a/packages/examples/src/server/simpleSseServer.ts b/packages/examples/src/server/simpleSseServer.ts index 1cd10cd2d..90e3ef1d3 100644 --- a/packages/examples/src/server/simpleSseServer.ts +++ b/packages/examples/src/server/simpleSseServer.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { SSEServerTransport } from '../../server/sse.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; /** * This example server demonstrates the deprecated HTTP+SSE transport diff --git a/packages/examples/src/server/simpleStatelessStreamableHttp.ts b/packages/examples/src/server/simpleStatelessStreamableHttp.ts index 748d82fda..1b9400ec7 100644 --- a/packages/examples/src/server/simpleStatelessStreamableHttp.ts +++ b/packages/examples/src/server/simpleStatelessStreamableHttp.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; const getServer = () => { // Create an MCP server with implementation details diff --git a/packages/examples/src/server/simpleStreamableHttp.ts b/packages/examples/src/server/simpleStreamableHttp.ts index ca1363198..b06edac05 100644 --- a/packages/examples/src/server/simpleStreamableHttp.ts +++ b/packages/examples/src/server/simpleStreamableHttp.ts @@ -1,11 +1,11 @@ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import * as z from 'zod/v4'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; -import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk-server'; +import { requireBearerAuth } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import { CallToolResult, ElicitResultSchema, @@ -14,12 +14,12 @@ import { PrimitiveSchemaDefinition, ReadResourceResult, ResourceLink -} from '../../types.js'; +} from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../experimental/tasks/stores/in-memory.js'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; -import { OAuthMetadata } from '../../shared/auth.js'; -import { checkResourceAllowed } from '../../shared/auth-utils.js'; +import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; +import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); diff --git a/packages/examples/src/server/simpleTaskInteractive.ts b/packages/examples/src/server/simpleTaskInteractive.ts index db0a4b579..7dad04c30 100644 --- a/packages/examples/src/server/simpleTaskInteractive.ts +++ b/packages/examples/src/server/simpleTaskInteractive.ts @@ -11,9 +11,9 @@ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { Server } from '../../server/index.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { CallToolResult, CreateTaskResult, @@ -36,9 +36,9 @@ import { GetTaskRequestSchema, GetTaskPayloadRequestSchema, GetTaskPayloadResult -} from '../../types.js'; -import { TaskMessageQueue, QueuedMessage, QueuedRequest, isTerminal, CreateTaskOptions } from '../../experimental/tasks/interfaces.js'; -import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; +} from '@modelcontextprotocol/sdk-server'; +import { TaskMessageQueue, QueuedMessage, QueuedRequest, isTerminal, CreateTaskOptions } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; // ============================================================================ // Resolver - Promise-like for passing results between async operations diff --git a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts index 5c91b7e33..01e7853fe 100644 --- a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts +++ b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts @@ -1,12 +1,12 @@ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { SSEServerTransport } from '../../server/sse.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '../../types.js'; +import { CallToolResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; /** * This example server demonstrates backwards compatibility with both: diff --git a/packages/examples/src/server/ssePollingExample.ts b/packages/examples/src/server/ssePollingExample.ts index bbecf2fdb..21ef8b042 100644 --- a/packages/examples/src/server/ssePollingExample.ts +++ b/packages/examples/src/server/ssePollingExample.ts @@ -14,10 +14,10 @@ */ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { CallToolResult } from '../../types.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { CallToolResult } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import cors from 'cors'; diff --git a/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts b/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts index 546d35c70..344021554 100644 --- a/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts +++ b/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { isInitializeRequest, ReadResourceResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { isInitializeRequest, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create an MCP server with implementation details const server = new McpServer({ diff --git a/packages/examples/src/server/toolWithSampleServer.ts b/packages/examples/src/server/toolWithSampleServer.ts index e6d733598..e1ab1acab 100644 --- a/packages/examples/src/server/toolWithSampleServer.ts +++ b/packages/examples/src/server/toolWithSampleServer.ts @@ -1,7 +1,7 @@ // Run with: npx tsx src/examples/server/toolWithSampleServer.ts -import { McpServer } from '../../server/mcp.js'; -import { StdioServerTransport } from '../../server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; const mcpServer = new McpServer({ diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index 4e641e6a3..cbd47e6ab 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -7,7 +7,7 @@ "paths": { "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"] + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/shared/src/index.ts"] } } } diff --git a/packages/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts index 8e973e587..e945b2374 100644 --- a/packages/server/src/experimental/tasks/index.ts +++ b/packages/server/src/experimental/tasks/index.ts @@ -8,9 +8,6 @@ // SDK implementation interfaces export * from './interfaces.js'; -// Assertion helpers -export * from './helpers.js'; - // Wrapper classes export * from './server.js'; export * from './mcp-server.js'; diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index bc0708f1d..1e604db4b 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -4,22 +4,11 @@ */ import { - Task, - RequestId, Result, - JSONRPCRequest, - JSONRPCNotification, - JSONRPCResultResponse, - JSONRPCErrorResponse, - ServerRequest, - ServerNotification, CallToolResult, - GetTaskResult, - ToolExecution, - Request -} from '@modelcontextprotocol/shared'; + GetTaskResult} from '@modelcontextprotocol/shared'; import { CreateTaskResult } from '@modelcontextprotocol/shared'; -import type { RequestHandlerExtra, RequestTaskStore } from '@modelcontextprotocol/shared'; +import type { CreateTaskRequestHandlerExtra, TaskRequestHandlerExtra } from '@modelcontextprotocol/shared'; import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; import { BaseToolCallback } from 'src/server/mcp.js'; @@ -27,23 +16,6 @@ import { BaseToolCallback } from 'src/server/mcp.js'; // Task Handler Types (for registerToolTask) // ============================================================================ -/** - * Extended handler extra with task store for task creation. - * @experimental - */ -export interface CreateTaskRequestHandlerExtra extends RequestHandlerExtra { - taskStore: RequestTaskStore; -} - -/** - * Extended handler extra with task ID and store for task operations. - * @experimental - */ -export interface TaskRequestHandlerExtra extends RequestHandlerExtra { - taskId: string; - taskStore: RequestTaskStore; -} - /** * Handler for creating a task. * @experimental @@ -71,206 +43,3 @@ export interface ToolTaskHandler; getTaskResult: TaskRequestHandler; } - -/** - * 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 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 QueuedMessage must be JSON-serializable. - * - * @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 { - /** - * Time in milliseconds to keep task results available after completion. - * 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 Transport, this allows pluggable task storage implementations - * (in-memory, database, distributed cache, etc.). - * - * @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 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 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 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/server/src/experimental/tasks/mcp-server.ts b/packages/server/src/experimental/tasks/mcp-server.ts index 781107430..12d100203 100644 --- a/packages/server/src/experimental/tasks/mcp-server.ts +++ b/packages/server/src/experimental/tasks/mcp-server.ts @@ -8,7 +8,8 @@ import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; import type { ToolAnnotations, ToolExecution } from '@modelcontextprotocol/shared'; -import type { ToolTaskHandler, TaskToolExecution } from './interfaces.js'; +import type { ToolTaskHandler } from './interfaces.js'; +import type { TaskToolExecution } from '@modelcontextprotocol/shared'; /** * Internal interface for accessing McpServer's private _createRegisteredTool method. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9a0fe413e..7cf838144 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,39 +6,11 @@ export * from './server/sse.js'; export * from './server/stdio.js'; export * from './server/streamableHttp.js'; -export * from './validation/ajv-provider.js'; -export * from './validation/cfworker-provider.js'; -export * from './experimental/tasks/index.js'; +// auth exports +export * from './server/auth/index.js'; + +// experimental exports +export * from './experimental/index.js'; // re-export shared types export * from '@modelcontextprotocol/shared'; -/** - * JSON Schema validation - * - * This module provides configurable JSON Schema validation for the MCP SDK. - * Choose a validator based on your runtime environment: - * - * - AjvJsonSchemaValidator: Best for Node.js (default, fastest) - * Import from: @modelcontextprotocol/sdk/validation/ajv - * Requires peer dependencies: ajv, ajv-formats - * - * - CfWorkerJsonSchemaValidator: Best for edge runtimes - * Import from: @modelcontextprotocol/sdk/validation/cfworker - * Requires peer dependency: @cfworker/json-schema - * - * @example - * ```typescript - * // For Node.js with AJV - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; - * const validator = new AjvJsonSchemaValidator(); - * - * // For Cloudflare Workers - * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; - * const validator = new CfWorkerJsonSchemaValidator(); - * ``` - * - * @module validation - */ - -// Core types only - implementations are exported via separate entry points -export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './validation/types.js'; diff --git a/packages/server/src/server/auth/clients.ts b/packages/server/src/server/auth/clients.ts index 4e3f8e17e..c89f5db99 100644 --- a/packages/server/src/server/auth/clients.ts +++ b/packages/server/src/server/auth/clients.ts @@ -1,4 +1,4 @@ -import { OAuthClientInformationFull } from '../../shared/auth.js'; +import { OAuthClientInformationFull } from '@modelcontextprotocol/shared'; /** * Stores information about registered OAuth clients for this server. diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts index dcb6c03ec..6e9f86b49 100644 --- a/packages/server/src/server/auth/handlers/authorize.ts +++ b/packages/server/src/server/auth/handlers/authorize.ts @@ -4,7 +4,7 @@ import express from 'express'; import { OAuthServerProvider } from '../provider.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; +import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts index e0f07a99b..04cd8c8fd 100644 --- a/packages/server/src/server/auth/handlers/metadata.ts +++ b/packages/server/src/server/auth/handlers/metadata.ts @@ -1,5 +1,5 @@ import express, { RequestHandler } from 'express'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../shared/auth.js'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/shared'; import cors from 'cors'; import { allowedMethods } from '../middleware/allowedMethods.js'; diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts index 1830619b4..1be1ec63d 100644 --- a/packages/server/src/server/auth/handlers/register.ts +++ b/packages/server/src/server/auth/handlers/register.ts @@ -1,11 +1,11 @@ import express, { RequestHandler } from 'express'; -import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../shared/auth.js'; +import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '@modelcontextprotocol/shared'; import crypto from 'node:crypto'; import cors from 'cors'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; +import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; export type ClientRegistrationHandlerOptions = { /** diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts index da7ef04f8..7805cd394 100644 --- a/packages/server/src/server/auth/handlers/revoke.ts +++ b/packages/server/src/server/auth/handlers/revoke.ts @@ -2,10 +2,10 @@ import { OAuthServerProvider } from '../provider.js'; import express, { RequestHandler } from 'express'; import cors from 'cors'; import { authenticateClient } from '../middleware/clientAuth.js'; -import { OAuthTokenRevocationRequestSchema } from '../../../shared/auth.js'; +import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/shared'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; +import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; export type RevocationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts index 4cc4e8ab8..b42da6cac 100644 --- a/packages/server/src/server/auth/handlers/token.ts +++ b/packages/server/src/server/auth/handlers/token.ts @@ -13,7 +13,7 @@ import { ServerError, TooManyRequestsError, OAuthError -} from '../errors.js'; +} from '@modelcontextprotocol/shared'; export type TokenHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/index.ts b/packages/server/src/server/auth/index.ts new file mode 100644 index 000000000..2c5ea4aab --- /dev/null +++ b/packages/server/src/server/auth/index.ts @@ -0,0 +1,15 @@ +export * from './clients.js'; +export * from './provider.js'; +export * from './router.js'; + +export * from './handlers/authorize.js'; +export * from './handlers/metadata.js'; +export * from './handlers/register.js'; +export * from './handlers/revoke.js'; +export * from './handlers/token.js'; + +export * from './middleware/allowedMethods.js'; +export * from './middleware/bearerAuth.js'; +export * from './middleware/clientAuth.js'; + +export * from './providers/proxyProvider.js'; \ No newline at end of file diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts index 74633aa57..45e38e3c0 100644 --- a/packages/server/src/server/auth/middleware/allowedMethods.ts +++ b/packages/server/src/server/auth/middleware/allowedMethods.ts @@ -1,5 +1,5 @@ import { RequestHandler } from 'express'; -import { MethodNotAllowedError } from '../errors.js'; +import { MethodNotAllowedError } from '@modelcontextprotocol/shared'; /** * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts index dac653086..d00ee1c5c 100644 --- a/packages/server/src/server/auth/middleware/bearerAuth.ts +++ b/packages/server/src/server/auth/middleware/bearerAuth.ts @@ -1,7 +1,7 @@ import { RequestHandler } from 'express'; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js'; +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/shared'; import { OAuthTokenVerifier } from '../provider.js'; -import { AuthInfo } from '../types.js'; +import { AuthInfo } from '@modelcontextprotocol/shared'; export type BearerAuthMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts index 6cc6a1923..c1696a0e7 100644 --- a/packages/server/src/server/auth/middleware/clientAuth.ts +++ b/packages/server/src/server/auth/middleware/clientAuth.ts @@ -1,8 +1,8 @@ import * as z from 'zod/v4'; import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; -import { OAuthClientInformationFull } from '../../../shared/auth.js'; -import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; +import { OAuthClientInformationFull } from '@modelcontextprotocol/shared'; +import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '@modelcontextprotocol/shared'; export type ClientAuthenticationMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts index cf1c306de..6f354dcd1 100644 --- a/packages/server/src/server/auth/provider.ts +++ b/packages/server/src/server/auth/provider.ts @@ -1,7 +1,7 @@ import { Response } from 'express'; import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; -import { AuthInfo } from './types.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/shared'; +import { AuthInfo } from '@modelcontextprotocol/shared'; export type AuthorizationParams = { state?: string; diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts index 855856c89..7f2258a72 100644 --- a/packages/server/src/server/auth/providers/proxyProvider.ts +++ b/packages/server/src/server/auth/providers/proxyProvider.ts @@ -6,11 +6,11 @@ import { OAuthTokenRevocationRequest, OAuthTokens, OAuthTokensSchema -} from '../../../shared/auth.js'; -import { AuthInfo } from '../types.js'; +} from '@modelcontextprotocol/shared'; +import { AuthInfo } from '@modelcontextprotocol/shared'; import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; -import { ServerError } from '../errors.js'; -import { FetchLike } from '../../../shared/transport.js'; +import { ServerError } from '@modelcontextprotocol/shared'; +import { FetchLike } from '@modelcontextprotocol/shared'; export type ProxyEndpoints = { authorizationUrl: string; diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts index 1df0be091..236138949 100644 --- a/packages/server/src/server/auth/router.ts +++ b/packages/server/src/server/auth/router.ts @@ -5,7 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from './handlers/au import { revocationHandler, RevocationHandlerOptions } from './handlers/revoke.js'; import { metadataHandler } from './handlers/metadata.js'; import { OAuthServerProvider } from './provider.js'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../shared/auth.js'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/shared'; // Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) const allowInsecureIssuerUrl = diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index be067ac55..204ba2e2b 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,4 +1,4 @@ -import { AnySchema, SchemaInput } from './zod-compat.js'; +import { AnySchema, SchemaInput } from '@modelcontextprotocol/shared'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 081ce525f..12540d428 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -42,8 +42,8 @@ import { type Notification, type Result } from '@modelcontextprotocol/shared'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/shared'; +import type { JsonSchemaType, jsonSchemaValidator } from '@modelcontextprotocol/shared'; import { AnyObjectSchema, getObjectShape, @@ -55,7 +55,7 @@ import { } from '@modelcontextprotocol/shared'; import { RequestHandlerExtra } from '@modelcontextprotocol/shared'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.js'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/shared'; export type ServerOptions = ProtocolOptions & { /** diff --git a/packages/server/src/server/sse.ts b/packages/server/src/server/sse.ts index b7450a09e..c9e618159 100644 --- a/packages/server/src/server/sse.ts +++ b/packages/server/src/server/sse.ts @@ -1,10 +1,10 @@ import { randomUUID } from 'node:crypto'; import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; +import { Transport } from '@modelcontextprotocol/shared'; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '@modelcontextprotocol/shared'; import getRawBody from 'raw-body'; import contentType from 'content-type'; -import { AuthInfo } from './auth/types.js'; +import { AuthInfo } from '@modelcontextprotocol/shared'; import { URL } from 'node:url'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index e552af0fa..6fd447bc2 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,8 +1,8 @@ import process from 'node:process'; import { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; -import { JSONRPCMessage } from '../types.js'; -import { Transport } from '../shared/transport.js'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/shared'; +import { JSONRPCMessage } from '@modelcontextprotocol/shared'; +import { Transport } from '@modelcontextprotocol/shared'; /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index ab1131f63..5cb880efe 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -1,5 +1,5 @@ import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../shared/transport.js'; +import { Transport } from '@modelcontextprotocol/shared'; import { MessageExtraInfo, RequestInfo, @@ -12,11 +12,11 @@ import { SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isJSONRPCErrorResponse -} from '../types.js'; +} from '@modelcontextprotocol/shared'; import getRawBody from 'raw-body'; import contentType from 'content-type'; import { randomUUID } from 'node:crypto'; -import { AuthInfo } from './auth/types.js'; +import { AuthInfo } from '@modelcontextprotocol/shared'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/src/server/auth/errors.ts b/packages/shared/src/auth/errors.ts similarity index 99% rename from packages/server/src/server/auth/errors.ts rename to packages/shared/src/auth/errors.ts index dff413e38..d77a9a0b1 100644 --- a/packages/server/src/server/auth/errors.ts +++ b/packages/shared/src/auth/errors.ts @@ -1,4 +1,4 @@ -import { OAuthErrorResponse } from '../../shared/auth.js'; +import { OAuthErrorResponse } from '../shared/auth.js'; /** * Base class for all OAuth errors diff --git a/packages/shared/src/experimental/index.ts b/packages/shared/src/experimental/index.ts new file mode 100644 index 000000000..351d03171 --- /dev/null +++ b/packages/shared/src/experimental/index.ts @@ -0,0 +1,2 @@ +export * from './tasks/helpers.js'; +export * from './tasks/interfaces.js'; \ No newline at end of file diff --git a/packages/server/src/experimental/tasks/helpers.ts b/packages/shared/src/experimental/tasks/helpers.ts similarity index 100% rename from packages/server/src/experimental/tasks/helpers.ts rename to packages/shared/src/experimental/tasks/helpers.ts diff --git a/packages/shared/src/experimental/tasks/interfaces.ts b/packages/shared/src/experimental/tasks/interfaces.ts new file mode 100644 index 000000000..272f6be61 --- /dev/null +++ b/packages/shared/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 { + Task, + RequestId, + Result, + JSONRPCRequest, + JSONRPCNotification, + JSONRPCResultResponse, + JSONRPCErrorResponse, + ServerRequest, + ServerNotification, + ToolExecution, + Request +} from '../../types/types.js'; +import type { RequestHandlerExtra, RequestTaskStore } from '../../shared/protocol.js'; + +// ============================================================================ +// Task Handler Types (for registerToolTask) +// ============================================================================ + +/** + * Extended handler extra with task store for task creation. + * @experimental + */ +export interface CreateTaskRequestHandlerExtra extends RequestHandlerExtra { + taskStore: RequestTaskStore; +} + +/** + * Extended handler extra with task ID and store for task operations. + * @experimental + */ +export interface TaskRequestHandlerExtra extends RequestHandlerExtra { + taskId: string; + taskStore: RequestTaskStore; +} + +/** + * 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 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 QueuedMessage must be JSON-serializable. + * + * @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 { + /** + * Time in milliseconds to keep task results available after completion. + * 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 Transport, this allows pluggable task storage implementations + * (in-memory, database, distributed cache, etc.). + * + * @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 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 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 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/shared/src/index.ts b/packages/shared/src/index.ts index f5af7c742..98955ac81 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,4 +12,42 @@ export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; -export * from './types/types.js'; \ No newline at end of file +export * from './types/types.js'; +export * from './auth/errors.js'; + +// experimental exports +export * from './experimental/index.js'; + +export * from './validation/ajv-provider.js'; +export * from './validation/cfworker-provider.js'; +/** + * JSON Schema validation + * + * This module provides configurable JSON Schema validation for the MCP SDK. + * Choose a validator based on your runtime environment: + * + * - AjvJsonSchemaValidator: Best for Node.js (default, fastest) + * Import from: @modelcontextprotocol/sdk/validation/ajv + * Requires peer dependencies: ajv, ajv-formats + * + * - CfWorkerJsonSchemaValidator: Best for edge runtimes + * Import from: @modelcontextprotocol/sdk/validation/cfworker + * Requires peer dependency: @cfworker/json-schema + * + * @example + * ```typescript + * // For Node.js with AJV + * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; + * const validator = new AjvJsonSchemaValidator(); + * + * // For Cloudflare Workers + * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; + * const validator = new CfWorkerJsonSchemaValidator(); + * ``` + * + * @module validation + */ + +// Core types only - implementations are exported via separate entry points +export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './validation/types.js'; + diff --git a/packages/server/src/validation/ajv-provider.ts b/packages/shared/src/validation/ajv-provider.ts similarity index 100% rename from packages/server/src/validation/ajv-provider.ts rename to packages/shared/src/validation/ajv-provider.ts diff --git a/packages/server/src/validation/cfworker-provider.ts b/packages/shared/src/validation/cfworker-provider.ts similarity index 100% rename from packages/server/src/validation/cfworker-provider.ts rename to packages/shared/src/validation/cfworker-provider.ts diff --git a/packages/server/src/validation/types.ts b/packages/shared/src/validation/types.ts similarity index 100% rename from packages/server/src/validation/types.ts rename to packages/shared/src/validation/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be2b7472e..e71479a1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,9 +278,6 @@ importers: '@modelcontextprotocol/sdk-server': specifier: workspace:^ version: link:../server - '@modelcontextprotocol/shared': - specifier: workspace:^ - version: link:../shared devDependencies: '@modelcontextprotocol/tsconfig': specifier: workspace:^ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a6a17ecd8..eeebf279e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,6 @@ packages: catalog: typescript: ^5.9.3 -# cleanupUnusedCatalogs: true # Breaks Dockerfile builds. Commented out until Dockerfile is updated to skip this step on the first pnpm install. enableGlobalVirtualStore: false From 8922ada0d9c8c9b2549451b8aecd11318a71d813 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 10:34:03 +0200 Subject: [PATCH 03/22] add vitest config --- common/vitest-config/vitest.config.ts | 10 ++++++++++ common/vitest-config/vitest.setup.ts | 8 ++++++++ 2 files changed, 18 insertions(+) create mode 100644 common/vitest-config/vitest.config.ts create mode 100644 common/vitest-config/vitest.setup.ts diff --git a/common/vitest-config/vitest.config.ts b/common/vitest-config/vitest.config.ts new file mode 100644 index 000000000..f283689f1 --- /dev/null +++ b/common/vitest-config/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + setupFiles: ['./vitest.setup.ts'], + include: ['test/**/*.test.ts'] + } +}); diff --git a/common/vitest-config/vitest.setup.ts b/common/vitest-config/vitest.setup.ts new file mode 100644 index 000000000..820dcbd89 --- /dev/null +++ b/common/vitest-config/vitest.setup.ts @@ -0,0 +1,8 @@ +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') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).crypto = webcrypto as unknown as Crypto; +} From ebeacac57d39ddafbb7d1fd6c7ac2ab53f323b77 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 10:34:17 +0200 Subject: [PATCH 04/22] clean up --- common/tsconfig/vitest.config.ts | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 common/tsconfig/vitest.config.ts diff --git a/common/tsconfig/vitest.config.ts b/common/tsconfig/vitest.config.ts deleted file mode 100644 index f283689f1..000000000 --- a/common/tsconfig/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - setupFiles: ['./vitest.setup.ts'], - include: ['test/**/*.test.ts'] - } -}); From d372e75141da14312062aa658181748b11466c99 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 10:59:25 +0200 Subject: [PATCH 05/22] save commit --- common/eslint-config/eslint.config.ts | 34 +++++++++++++++++++++++++++ common/eslint-config/package.json | 31 ++++++++++++++++++++++++ common/eslint-config/tsconfig.json | 8 +++++++ common/vitest-config/package.json | 2 +- packages/client/package.json | 1 + packages/server/package.json | 1 + packages/shared/package.json | 1 + pnpm-lock.yaml | 28 ++++++++++++++++++++++ tsconfig.json | 23 ------------------ 9 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 common/eslint-config/eslint.config.ts create mode 100644 common/eslint-config/package.json create mode 100644 common/eslint-config/tsconfig.json delete mode 100644 tsconfig.json diff --git a/common/eslint-config/eslint.config.ts b/common/eslint-config/eslint.config.ts new file mode 100644 index 000000000..f976b1b0a --- /dev/null +++ b/common/eslint-config/eslint.config.ts @@ -0,0 +1,34 @@ +// @ts-check + +import * as eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import * as eslintConfigPrettier from 'eslint-config-prettier/flat'; +import * as nodePlugin from 'eslint-plugin-n'; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + linterOptions: { + reportUnusedDisableDirectives: false + }, + plugins: { + n: nodePlugin + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'n/prefer-node-protocol': 'error' + } + }, + { + ignores: ['src/spec.types.ts'] + }, + { + files: ['src/client/**/*.ts', 'src/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 000000000..900e5a77c --- /dev/null +++ b/common/eslint-config/package.json @@ -0,0 +1,31 @@ +{ + "name": "@modelcontextprotocol/eslint-config", + "private": true, + "main": "tsconfig.json", + "type": "module", + "dependencies": { + "typescript": "catalog:" + }, + "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": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "eslint": "^9.8.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^17.23.1", + "prettier": "3.6.2", + "typescript": "^5.5.4", + "typescript-eslint": "^8.48.1", + "@eslint/js": "^9.39.1" + } +} diff --git a/common/eslint-config/tsconfig.json b/common/eslint-config/tsconfig.json new file mode 100644 index 000000000..32203633b --- /dev/null +++ b/common/eslint-config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json index ef389a90c..41c61b31d 100644 --- a/common/vitest-config/package.json +++ b/common/vitest-config/package.json @@ -13,7 +13,7 @@ "bugs": { "url": "https://github.com/modelcontextprotocol/typescript-sdk/issues" }, - "homepage": "https://github.com/modelcontextprotocol/typescript-sdk/tree/develop/common/ts-config", + "homepage": "https://github.com/modelcontextprotocol/typescript-sdk/tree/develop/common/vitest-config", "publishConfig": { "registry": "https://npm.pkg.github.com/" }, diff --git a/packages/client/package.json b/packages/client/package.json index cd24fb2de..4332bcebc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -83,6 +83,7 @@ "devDependencies": { "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", diff --git a/packages/server/package.json b/packages/server/package.json index 0b143d2f4..7a6cf5cd7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -83,6 +83,7 @@ "devDependencies": { "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", diff --git a/packages/shared/package.json b/packages/shared/package.json index 6ebe3b4ae..c6c5896ff 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -84,6 +84,7 @@ "devDependencies": { "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", "@cfworker/json-schema": "^4.1.1", "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71479a1b..e12aa9d0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,34 @@ importers: specifier: ^8.18.0 version: 8.18.3 + common/eslint-config: + dependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../tsconfig + eslint: + specifier: ^9.8.0 + version: 9.39.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.1) + eslint-plugin-n: + specifier: ^17.23.1 + version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 + typescript-eslint: + specifier: ^8.48.1 + version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) + common/tsconfig: dependencies: typescript: diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index c7346e4fe..000000000 --- a/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "es2018", - "module": "Node16", - "moduleResolution": "Node16", - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "./dist", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "skipLibCheck": true, - "paths": { - "pkce-challenge": ["./node_modules/pkce-challenge/dist/index.node"] - }, - "types": ["node", "vitest/globals"] - }, - "include": ["src/**/*", "test/**/*"], - "exclude": ["node_modules", "dist"] -} From 4db8d1b1dd445d801209bd69be24a05420b2abc9 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 10 Dec 2025 17:36:13 +0200 Subject: [PATCH 06/22] eslint, vitest setup; successful shared/ tests pass --- .../eslint-config/eslint.config.mjs | 14 +- common/eslint-config/package.json | 5 +- common/vitest-config/package.json | 6 +- .../{vitest.config.ts => vitest.config.mjs} | 2 + packages/client/eslint.config.mjs | 7 + .../test/client/auth-extensions.test.ts | 331 + packages/client/test/client/auth.test.ts | 3247 ++++++++++ .../client/test/client/cross-spawn.test.ts | 153 + packages/client/test/client/index.test.ts | 4139 ++++++++++++ .../client/test/client/middleware.test.ts | 1118 ++++ packages/client/test/client/sse.test.ts | 1506 +++++ packages/client/test/client/stdio.test.ts | 77 + .../client/test/client/streamableHttp.test.ts | 1626 +++++ .../experimental/tasks/task-listing.test.ts | 128 + .../test/experimental/tasks/task.test.ts | 117 + packages/client/tsconfig.json | 4 +- packages/client/vitest.setup.ts | 3 + packages/examples/eslint.config.mjs | 7 + packages/examples/package.json | 4 +- packages/examples/tsconfig.json | 4 +- packages/server/eslint.config.mjs | 7 + packages/server/vitest.setup.ts | 3 + packages/shared/eslint.config.mjs | 7 + packages/shared/src/experimental/index.ts | 3 +- .../experimental/tasks/stores/in-memory.ts | 0 packages/shared/src/shared/protocol.ts | 8 +- packages/shared/src/shared/stdio.ts | 2 +- packages/shared/test/.gitkeep | 0 .../shared/test/shared/auth-utils.test.ts | 90 + packages/shared/test/shared/auth.test.ts | 122 + .../protocol-transport-handling.test.ts | 189 + packages/shared/test/shared/protocol.test.ts | 5558 +++++++++++++++++ packages/shared/test/shared/stdio.test.ts | 35 + .../test/shared/toolNameValidation.test.ts | 128 + .../shared/test/shared/uriTemplate.test.ts | 288 + packages/shared/tsconfig.json | 4 + packages/shared/vitest.setup.ts | 3 + pnpm-lock.yaml | 15 + 38 files changed, 18948 insertions(+), 12 deletions(-) rename eslint.config.mjs => common/eslint-config/eslint.config.mjs (65%) rename common/vitest-config/{vitest.config.ts => vitest.config.mjs} (99%) create mode 100644 packages/client/eslint.config.mjs create mode 100644 packages/client/test/client/auth-extensions.test.ts create mode 100644 packages/client/test/client/auth.test.ts create mode 100644 packages/client/test/client/cross-spawn.test.ts create mode 100644 packages/client/test/client/index.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/experimental/tasks/task-listing.test.ts create mode 100644 packages/client/test/experimental/tasks/task.test.ts create mode 100644 packages/client/vitest.setup.ts create mode 100644 packages/examples/eslint.config.mjs create mode 100644 packages/server/eslint.config.mjs create mode 100644 packages/server/vitest.setup.ts create mode 100644 packages/shared/eslint.config.mjs rename packages/{server => shared}/src/experimental/tasks/stores/in-memory.ts (100%) delete mode 100644 packages/shared/test/.gitkeep create mode 100644 packages/shared/test/shared/auth-utils.test.ts create mode 100644 packages/shared/test/shared/auth.test.ts create mode 100644 packages/shared/test/shared/protocol-transport-handling.test.ts create mode 100644 packages/shared/test/shared/protocol.test.ts create mode 100644 packages/shared/test/shared/stdio.test.ts create mode 100644 packages/shared/test/shared/toolNameValidation.test.ts create mode 100644 packages/shared/test/shared/uriTemplate.test.ts create mode 100644 packages/shared/vitest.setup.ts diff --git a/eslint.config.mjs b/common/eslint-config/eslint.config.mjs similarity index 65% rename from eslint.config.mjs rename to common/eslint-config/eslint.config.mjs index fdfab80e2..d30cd8208 100644 --- a/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -4,11 +4,21 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier/flat'; import nodePlugin from 'eslint-plugin-n'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, { + languageOptions: { + parserOptions: { + // Ensure consumers of this shared config get a stable tsconfig root + tsconfigRootDir: __dirname + } + }, linterOptions: { reportUnusedDisableDirectives: false }, @@ -21,7 +31,8 @@ export default tseslint.config( } }, { - ignores: ['src/spec.types.ts'] + // Ignore generated protocol types everywhere + ignores: ['**/spec.types.ts'] }, { files: ['src/client/**/*.ts', 'src/server/**/*.ts'], @@ -32,3 +43,4 @@ export default tseslint.config( }, eslintConfigPrettier ); + diff --git a/common/eslint-config/package.json b/common/eslint-config/package.json index 900e5a77c..df5606aac 100644 --- a/common/eslint-config/package.json +++ b/common/eslint-config/package.json @@ -1,8 +1,11 @@ { "name": "@modelcontextprotocol/eslint-config", "private": true, - "main": "tsconfig.json", + "main": "eslint.config.mjs", "type": "module", + "exports": { + ".": "./eslint.config.mjs" + }, "dependencies": { "typescript": "catalog:" }, diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json index 41c61b31d..3a11922b3 100644 --- a/common/vitest-config/package.json +++ b/common/vitest-config/package.json @@ -1,8 +1,12 @@ { "name": "@modelcontextprotocol/vitest-config", "private": true, - "main": "tsconfig.json", + "main": "vitest.config.mjs", "type": "module", + "exports": { + ".": "./vitest.config.mjs", + "./tsconfig.json": "./tsconfig.json" + }, "dependencies": { "typescript": "catalog:" }, diff --git a/common/vitest-config/vitest.config.ts b/common/vitest-config/vitest.config.mjs similarity index 99% rename from common/vitest-config/vitest.config.ts rename to common/vitest-config/vitest.config.mjs index f283689f1..55f1b6aca 100644 --- a/common/vitest-config/vitest.config.ts +++ b/common/vitest-config/vitest.config.mjs @@ -8,3 +8,5 @@ export default defineConfig({ include: ['test/**/*.test.ts'] } }); + + diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs new file mode 100644 index 000000000..70e926598 --- /dev/null +++ b/packages/client/eslint.config.mjs @@ -0,0 +1,7 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; + + diff --git a/packages/client/test/client/auth-extensions.test.ts b/packages/client/test/client/auth-extensions.test.ts new file mode 100644 index 000000000..a7217307d --- /dev/null +++ b/packages/client/test/client/auth-extensions.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect } from 'vitest'; +import { auth } from '../../src/client/auth.js'; +import { + ClientCredentialsProvider, + PrivateKeyJwtProvider, + StaticPrivateKeyJwtProvider, + createPrivateKeyJwtAuth +} from '../../src/client/auth-extensions.js'; +import { createMockOAuthFetch } from '../helpers/oauth.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('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('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'); + }); +}); + +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 add 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( + /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/ + ); + }); +}); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts new file mode 100644 index 000000000..d6e7e8684 --- /dev/null +++ b/packages/client/test/client/auth.test.ts @@ -0,0 +1,3247 @@ +import { LATEST_PROTOCOL_VERSION } from '../../src/types.js'; +import { + discoverOAuthMetadata, + discoverAuthorizationServerMetadata, + buildDiscoveryUrls, + startAuthorization, + exchangeAuthorization, + refreshAuthorization, + registerClient, + discoverOAuthProtectedResourceMetadata, + extractWWWAuthenticateParams, + auth, + type OAuthClientProvider, + selectClientAuthMethod, + isHttpsUrl +} from '../../src/client/auth.js'; +import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; +import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; +import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js'; +import { expect, vi, type Mock } from 'vitest'; + +// Mock pkce-challenge +vi.mock('pkce-challenge', () => ({ + default: () => ({ + code_verifier: 'test_verifier', + code_challenge: 'test_challenge' + }) +})); + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('OAuth Authorization', () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + 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', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call with MCP header - fail with TypeError (simulating CORS error) + // We need to use TypeError specifically because that's what the implementation checks for + return Promise.reject(new TypeError('Network error')); + } else { + // Second call without header - succeed + return 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', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call - fail with TypeError + return Promise.reject(new TypeError('First failure')); + } else { + // Second call - fail with different error + return 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 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 error on 500 status and does not fallback', async () => { + // First call (path-aware) returns 500 + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow(); + + const calls = mockFetch.mock.calls; + expect(calls.length).toBe(1); // Should not attempt fallback + }); + + 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', async () => { + // 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', async () => { + // 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', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call with MCP header - fail with TypeError (simulating CORS error) + // We need to use TypeError specifically because that's what the implementation checks for + return Promise.reject(new TypeError('Network error')); + } else { + // Second call without header - succeed + return 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', async () => { + // Set up a counter to control behavior + let callCount = 0; + + // Mock implementation that changes behavior based on call count + mockFetch.mockImplementation((_url, _options) => { + callCount++; + + if (callCount === 1) { + // First call - fail with TypeError + return Promise.reject(new TypeError('First failure')); + } else { + // Second call - fail with different error + return 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', async () => { + // 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', async () => { + mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); + + await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); + }); + + 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('throws on non-4xx errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500 + }); + + await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500'); + }); + + it('handles CORS errors with retry', async () => { + // 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', async () => { + // 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) + }); + }); + + 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'); + }); + }); + + 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'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('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'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('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 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'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('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'); + expect(body.get('client_id')).toBe('client123'); + expect(body.get('client_secret')).toBe('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 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: 1612137600, + client_secret_expires_at: 1612224000, + ...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('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 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() { + 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: 1612137600, + client_secret_expires_at: 1612224000, + 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: 1612137600, + client_secret_expires_at: 1612224000, + 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'); + }); + + 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_post when no auth methods specified', 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]; + + // Check headers + expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); + 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'); + }); + }); + + 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('../../src/shared/transport.js'); + + 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('../../src/shared/transport.js'); + + 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('../../src/shared/transport.js'); + + 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.toThrow(InvalidClientMetadataError); + }); + + 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.toThrow(InvalidClientMetadataError); + }); + + 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.toThrow(InvalidClientMetadataError); + }); + + 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'] + }); + }); + }); +}); diff --git a/packages/client/test/client/cross-spawn.test.ts b/packages/client/test/client/cross-spawn.test.ts new file mode 100644 index 000000000..26ae682fe --- /dev/null +++ b/packages/client/test/client/cross-spawn.test.ts @@ -0,0 +1,153 @@ +import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/stdio.js'; +import spawn from 'cross-spawn'; +import { JSONRPCMessage } from '../../src/types.js'; +import { ChildProcess } from 'node:child_process'; +import { Mock, MockedFunction } from 'vitest'; + +// 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(); + }); +}); diff --git a/packages/client/test/client/index.test.ts b/packages/client/test/client/index.test.ts new file mode 100644 index 000000000..9735eb2ba --- /dev/null +++ b/packages/client/test/client/index.test.ts @@ -0,0 +1,4139 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-constant-binary-expression */ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { Client, getSupportedElicitationModes } from '../../src/client/index.js'; +import { + RequestSchema, + NotificationSchema, + ResultSchema, + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + InitializeRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, + ListPromptsRequestSchema, + CallToolRequestSchema, + CallToolResultSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ElicitResultSchema, + ListRootsRequestSchema, + ErrorCode, + McpError, + CreateTaskResultSchema, + Tool, + Prompt, + Resource +} from '../../src/types.js'; +import { Transport } from '../../src/shared/transport.js'; +import { Server } from '../../src/server/index.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { InMemoryTransport } from '../../src/inMemory.js'; +import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; + +describe('Zod v4', () => { + /*** + * Test: Type Checking + * Test that custom request/notification/result schemas can be used with the Client class. + */ + test('should typecheck', () => { + const GetWeatherRequestSchema = RequestSchema.extend({ + method: z4.literal('weather/get'), + params: z4.object({ + city: z4.string() + }) + }); + + const GetForecastRequestSchema = RequestSchema.extend({ + method: z4.literal('weather/forecast'), + params: z4.object({ + city: z4.string(), + days: z4.number() + }) + }); + + const WeatherForecastNotificationSchema = NotificationSchema.extend({ + method: z4.literal('weather/alert'), + params: z4.object({ + severity: z4.enum(['warning', 'watch']), + message: z4.string() + }) + }); + + const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); + const WeatherNotificationSchema = WeatherForecastNotificationSchema; + const WeatherResultSchema = ResultSchema.extend({ + temperature: z4.number(), + conditions: z4.string() + }); + + type WeatherRequest = z4.infer; + type WeatherNotification = z4.infer; + type WeatherResult = z4.infer; + + // Create a typed Client for weather data + const weatherClient = new Client( + { + name: 'WeatherClient', + version: '1.0.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Typecheck that only valid weather requests/notifications/results are allowed + false && + weatherClient.request( + { + method: 'weather/get', + params: { + city: 'Seattle' + } + }, + WeatherResultSchema + ); + + false && + weatherClient.notification({ + method: 'weather/alert', + params: { + severity: 'warning', + message: 'Storm approaching' + } + }); + }); +}); + +describe('Zod v3', () => { + /*** + * Test: Type Checking + * Test that custom request/notification/result schemas can be used with the Client class. + */ + test('should typecheck', () => { + const GetWeatherRequestSchema = z3.object({ + ...RequestSchema.shape, + method: z3.literal('weather/get'), + params: z3.object({ + city: z3.string() + }) + }); + + const GetForecastRequestSchema = z3.object({ + ...RequestSchema.shape, + method: z3.literal('weather/forecast'), + params: z3.object({ + city: z3.string(), + days: z3.number() + }) + }); + + const WeatherForecastNotificationSchema = z3.object({ + ...NotificationSchema.shape, + method: z3.literal('weather/alert'), + params: z3.object({ + severity: z3.enum(['warning', 'watch']), + message: z3.string() + }) + }); + + const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); + const WeatherNotificationSchema = WeatherForecastNotificationSchema; + const WeatherResultSchema = z3.object({ + ...ResultSchema.shape, + _meta: z3.record(z3.string(), z3.unknown()).optional(), + temperature: z3.number(), + conditions: z3.string() + }); + + type WeatherRequest = z3.infer; + type WeatherNotification = z3.infer; + type WeatherResult = z3.infer; + + // Create a typed Client for weather data + const weatherClient = new Client( + { + name: 'WeatherClient', + version: '1.0.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Typecheck that only valid weather requests/notifications/results are allowed + false && + weatherClient.request( + { + method: 'weather/get', + params: { + city: 'Seattle' + } + }, + WeatherResultSchema + ); + + false && + weatherClient.notification({ + method: 'weather/alert', + params: { + severity: 'warning', + message: 'Storm approaching' + } + }); + }); +}); + +/*** + * 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: 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(InitializeRequestSchema, _request => ({ + protocolVersion: OLD_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'old server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + 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 OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + 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 OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const FUTURE_VERSION = 'FUTURE_VERSION'; + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: FUTURE_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + 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(InitializeRequestSchema, _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'test', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + 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: 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(CreateMessageRequestSchema, () => ({ + 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(ListRootsRequestSchema, () => ({})); + }).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(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + 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(ElicitRequestSchema, 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(ElicitRequestSchema, 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(ErrorCode.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(ElicitRequestSchema, 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' + } + } + } + } + }, + ElicitResultSchema + ) + ).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(ElicitRequestSchema, 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(ErrorCode.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(ElicitRequestSchema, 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(ListResourcesRequestSchema, async (request, extra) => { + 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 McpError + await expect(listResourcesPromise).rejects.toThrow(McpError); +}); + +/*** + * 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(ListResourcesRequestSchema, async (_request, extra) => { + const timer = new Promise(resolve => { + const timeout = setTimeout(resolve, 100); + extra.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: ErrorCode.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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, // No listChanged: true + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(InitializeRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: { tools: { listChanged: true }, prompts: {} }, + serverInfo: { name: 'test-server', version: '1.0.0' } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }] + })); + + server.setRequestHandler(ListPromptsRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + // No outputSchema + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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' + }); + + 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: {} + }, + { + async createTask(_args, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Tool executed successfully!' }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + 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: {} }, CallToolResultSchema, { + task: { + ttl: 60000 + } + }); + + // 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: {} + }, + { + async createTask(_args, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Create a task + await client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, { + task: { ttl: 60000 } + }); + + // 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: {} + }, + { + async createTask(_args, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Result data!' }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + 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: {} }, CallToolResultSchema, { + task: { ttl: 60000 } + }); + + 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: {} + }, + { + async createTask(_args, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + 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: {} }, CallToolResultSchema, { + task: { ttl: 60000 } + }); + + // 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(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + await extra.taskStore.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'] + } + } + }, + CreateTaskResultSchema, + { task: { ttl: 60000 } } + ); + + // 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(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + await extra.taskStore.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' } } + } + } + }, + CreateTaskResultSchema, + { task: { ttl: 60000 } } + ); + + // 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(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept', + content: { username: 'result-user' } + }; + + // Check if task creation is requested + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + await extra.taskStore.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' } } + } + } + }, + CreateTaskResultSchema, + { task: { ttl: 60000 } } + ); + + // 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(ElicitRequestSchema, async (request, extra) => { + const result = { + action: 'accept', + content: { username: 'list-user' } + }; + + // Check if task creation is requested + if (request.params.task && extra.taskStore) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + await extra.taskStore.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' } } + } + } + }, + CreateTaskResultSchema, + { task: { ttl: 60000 } } + ); + + // 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: { + id: z4.string() + } + }, + { + async createTask({ id }, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: `Result for ${id || 'unknown'}` }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + 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}` } }, CallToolResultSchema, { + task: { ttl: 60000 } + }); + + // 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(ElicitRequestSchema, 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: {} + }, + { + async createTask(_args, extra) { + const task = await extra.taskStore.createTask({ + ttl: extra.taskRequestedTtl + }); + + const result = { + content: [{ type: 'text', text: 'Success!' }] + }; + await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); + + return { task }; + }, + async getTask(_args, extra) { + const task = await extra.taskStore.getTask(extra.taskId); + if (!task) { + throw new Error(`Task ${extra.taskId} not found`); + } + return task; + }, + async getTaskResult(_args, extra) { + const result = await extra.taskStore.getTaskResult(extra.taskId); + return result as { content: Array<{ type: 'text'; text: string }> }; + } + } + ); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + enforceStrictCapabilities: true + } + ); + + 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: {} }, CallToolResultSchema, { + task: { ttl: 60000 } + }) + ).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: {} + }, + ListToolsResultSchema + ) + ).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(CallToolRequestSchema, 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: {} + } + ); + + 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: {} } + }, + CallToolResultSchema + ); + + 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(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} + } + ); + + 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(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, 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: {} + } + ); + + 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); + }); +}); diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts new file mode 100644 index 000000000..06bda69c8 --- /dev/null +++ b/packages/client/test/client/middleware.test.ts @@ -0,0 +1,1118 @@ +import { withOAuth, withLogging, applyMiddlewares, createMiddleware } from '../../src/client/middleware.js'; +import { OAuthClientProvider } from '../../src/client/auth.js'; +import { FetchLike } from '../../src/shared/transport.js'; +import { MockInstance, Mocked, MockedFunction } from 'vitest'; + +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(); + const transformed = { ...data, timestamp: 123456789 }; + + return new Response(JSON.stringify(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: 123456789 + }); + }); + + 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 000000000..6574b60b8 --- /dev/null +++ b/packages/client/test/client/sse.test.ts @@ -0,0 +1,1506 @@ +import { createServer, ServerResponse, type IncomingMessage, type Server } from 'node:http'; +import { JSONRPCMessage } from '../../src/types.js'; +import { SSEClientTransport } from '../../src/client/sse.js'; +import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; +import { OAuthTokens } from '../../src/shared/auth.js'; +import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; +import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; +import { listenOnRandomPort } from '../helpers/http.js'; +import { AddressInfo } from 'node:net'; + +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 = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + const calledHeaders = (global.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 = (global.fetch as Mock).mock.calls[1][1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + global.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 = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: '1', + method: 'test', + params: {} + }; + + await transport.send(message); + + const calledHeaders = (global.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 = (global.fetch as Mock).mock.calls[1][1].headers; + expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); + } finally { + global.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 = global.fetch; + try { + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + + await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); + + const calledHeaders = (global.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 { + global.fetch = originalFetch; + } + }); + }); + + describe('auth handling', () => { + const authServerMetadataUrls = ['/.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(404).end(); + } else { + res.writeHead(401).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.includes(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); + if ( + params.get('grant_type') === 'refresh_token' && + params.get('refresh_token') === 'refresh-token' && + params.get('client_id') === 'test-client-id' && + params.get('client_secret') === '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.includes(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); + if ( + params.get('grant_type') === 'refresh_token' && + params.get('refresh_token') === 'refresh-token' && + params.get('client_id') === 'test-client-id' && + params.get('client_secret') === '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.includes(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 InvalidClientError 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' + }); + + let baseUrl = resourceBaseUrl; + + // Create server that returns InvalidClientError 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') { + // Handle token refresh request - return InvalidClientError + const error = new InvalidClientError('Client authentication failed'); + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(InvalidClientError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it('invalidates all credentials on UnauthorizedClientError 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' + }); + + 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') { + // Handle token refresh request - return UnauthorizedClientError + const error = new UnauthorizedClientError('Client not authorized'); + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(UnauthorizedClientError); + expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); + }); + + it('invalidates tokens on InvalidGrantError 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' + }); + 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') { + // Handle token refresh request - return InvalidGrantError + const error = new InvalidGrantError('Invalid refresh token'); + res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(InvalidGrantError); + 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); + if ( + params.get('grant_type') === 'authorization_code' && + params.get('code') === 'test-auth-code' && + params.get('client_id') === 'test-client-id' + ) { + 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(global, '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(); + }); + }); +}); diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts new file mode 100644 index 000000000..52a871ee1 --- /dev/null +++ b/packages/client/test/client/stdio.test.ts @@ -0,0 +1,77 @@ +import { JSONRPCMessage } from '../../src/types.js'; +import { StdioClientTransport, StdioServerParameters } 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 000000000..52c8f1074 --- /dev/null +++ b/packages/client/test/client/streamableHttp.test.ts @@ -0,0 +1,1626 @@ +import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js'; +import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; +import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js'; +import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; +import { type Mock, type Mocked } from 'vitest'; + +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(global, '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' + }; + + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send(message); + + expect(global.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' } + ]; + + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: null + }); + + await transport.send(messages); + + expect(global.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' + }; + + (global.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 + (global.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 = (global.fetch as Mock).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1].headers).toBeDefined(); + expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); + }); + + 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' + }; + + (global.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 + (global.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 = (global.fetch as Mock).mock.calls; + const lastCall = calls[calls.length - 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' + }; + + (global.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 + (global.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' + }; + + (global.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('Streamable HTTP error: Error POSTing to endpoint: 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' + }; + + (global.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) + (global.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(global.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + headers: expect.any(Headers) + }) + ); + + // Verify transport still works after 405 + (global.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + expect(global.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 + (global.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)); + } + }); + }; + + (global.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: 10000, + 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 = global.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 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; + + (global.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(global.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 = {}; + + (global.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(global.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 = {}; + + (global.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(global.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 = {}; + + (global.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 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' + }; + + (global.fetch as Mock) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers(), + text: async () => Promise.reject('dont read my body') + }) + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => Promise.reject('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 = global.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 = global.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 = global.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 = global.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 = global.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 = global.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 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 = global.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 = global.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 InvalidClientError 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 () => Promise.reject('dont read my body') + }; + (global.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 InvalidClientError + .mockResolvedValueOnce( + Response.json(new InvalidClientError('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 UnauthorizedClientError 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 () => Promise.reject('dont read my body') + }; + (global.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 UnauthorizedClientError + .mockResolvedValueOnce(Response.json(new UnauthorizedClientError('Client not authorized').toResponseObject(), { status: 400 })) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => Promise.reject('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 InvalidGrantError 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 () => Promise.reject('dont read my body') + }; + (global.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 InvalidGrantError + .mockResolvedValueOnce(Response.json(new InvalidGrantError('Invalid refresh token').toResponseObject(), { status: 400 })) + // Fallback should fail to complete the flow + .mockResolvedValue({ + ok: false, + status: 404, + text: async () => Promise.reject('dont read my body') + }); + + // Behavior for InvalidGrantError 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 () => Promise.reject('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 InvalidClientError + .mockResolvedValueOnce( + Response.json(new InvalidClientError('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(global.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(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('SSE retry field handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + (global.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 = global.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 = global.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 timeout was scheduled (no reconnection attempt) + expect(transport['_reconnectionTimeout']).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['_reconnectionTimeout']).toBeDefined(); + + // Clean up the timeout to avoid test pollution + clearTimeout(transport['_reconnectionTimeout']); + }); + }); + + 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 () => Promise.reject('dont read my body') + }; + + (global.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); + + await expect(transport.send(message)).rejects.toThrow('Server returned 401 after successful authentication'); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh-token' // Refresh token is preserved + }); + }); + }); +}); diff --git a/packages/client/test/experimental/tasks/task-listing.test.ts b/packages/client/test/experimental/tasks/task-listing.test.ts new file mode 100644 index 000000000..bf51f1404 --- /dev/null +++ b/packages/client/test/experimental/tasks/task-listing.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { ErrorCode, McpError } from '../../../src/types.js'; +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: McpError) => { + expect(error).toBeInstanceOf(McpError); + expect(error.code).toBe(ErrorCode.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/packages/client/test/experimental/tasks/task.test.ts b/packages/client/test/experimental/tasks/task.test.ts new file mode 100644 index 000000000..37e3938d2 --- /dev/null +++ b/packages/client/test/experimental/tasks/task.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { isTerminal } from '../../../src/experimental/tasks/interfaces.js'; +import type { Task } from '../../../src/types.js'; + +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: 60000, + createdAt, + lastUpdatedAt: createdAt, + pollInterval: 1000 + }; + + expect(task.ttl).toBe(60000); + 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: 30000, + 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: 30000, + 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(); + statuses.forEach(status => { + const task: Task = { + taskId: `test-${status}`, + status, + ttl: null, + createdAt, + lastUpdatedAt: createdAt + }; + expect(task.status).toBe(status); + }); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 12c974280..e84c3a8fe 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -5,7 +5,9 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"] + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"] } } } diff --git a/packages/client/vitest.setup.ts b/packages/client/vitest.setup.ts new file mode 100644 index 000000000..2c6606b9c --- /dev/null +++ b/packages/client/vitest.setup.ts @@ -0,0 +1,3 @@ +import '../../vitest.setup'; + + diff --git a/packages/examples/eslint.config.mjs b/packages/examples/eslint.config.mjs new file mode 100644 index 000000000..70e926598 --- /dev/null +++ b/packages/examples/eslint.config.mjs @@ -0,0 +1,7 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; + + diff --git a/packages/examples/package.json b/packages/examples/package.json index ba85817fd..4fcd3403e 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -40,6 +40,8 @@ "@modelcontextprotocol/sdk-client": "workspace:^" }, "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^" + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^" } } diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index cbd47e6ab..26c951611 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -7,7 +7,9 @@ "paths": { "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/shared/src/index.ts"] + "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/shared/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"] } } } diff --git a/packages/server/eslint.config.mjs b/packages/server/eslint.config.mjs new file mode 100644 index 000000000..70e926598 --- /dev/null +++ b/packages/server/eslint.config.mjs @@ -0,0 +1,7 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; + + diff --git a/packages/server/vitest.setup.ts b/packages/server/vitest.setup.ts new file mode 100644 index 000000000..2c6606b9c --- /dev/null +++ b/packages/server/vitest.setup.ts @@ -0,0 +1,3 @@ +import '../../vitest.setup'; + + diff --git a/packages/shared/eslint.config.mjs b/packages/shared/eslint.config.mjs new file mode 100644 index 000000000..70e926598 --- /dev/null +++ b/packages/shared/eslint.config.mjs @@ -0,0 +1,7 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; + + diff --git a/packages/shared/src/experimental/index.ts b/packages/shared/src/experimental/index.ts index 351d03171..7ca654d46 100644 --- a/packages/shared/src/experimental/index.ts +++ b/packages/shared/src/experimental/index.ts @@ -1,2 +1,3 @@ export * from './tasks/helpers.js'; -export * from './tasks/interfaces.js'; \ No newline at end of file +export * from './tasks/interfaces.js'; +export * from './tasks/stores/in-memory.js'; \ No newline at end of file diff --git a/packages/server/src/experimental/tasks/stores/in-memory.ts b/packages/shared/src/experimental/tasks/stores/in-memory.ts similarity index 100% rename from packages/server/src/experimental/tasks/stores/in-memory.ts rename to packages/shared/src/experimental/tasks/stores/in-memory.ts diff --git a/packages/shared/src/shared/protocol.ts b/packages/shared/src/shared/protocol.ts index aa242a647..b81ad4828 100644 --- a/packages/shared/src/shared/protocol.ts +++ b/packages/shared/src/shared/protocol.ts @@ -1,4 +1,4 @@ -import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js'; +import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../util/zod-compat.js'; import { CancelledNotificationSchema, ClientCapabilities, @@ -44,11 +44,11 @@ import { Notification, JSONRPCResultResponse, isTaskAugmentedRequestParams -} from '../types.js'; +} from '../types/types.js'; import { Transport, TransportSendOptions } from './transport.js'; -import { AuthInfo } from '../server/auth/types.js'; +import { AuthInfo } from '../types/types.js'; import { isTerminal, TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; -import { getMethodLiteral, parseWithCompat } from '../server/zod-json-schema-compat.js'; +import { getMethodLiteral, parseWithCompat } from '../util/zod-json-schema-compat.js'; import { ResponseMessage } from './responseMessage.js'; /** diff --git a/packages/shared/src/shared/stdio.ts b/packages/shared/src/shared/stdio.ts index fe14612bd..76e3940fa 100644 --- a/packages/shared/src/shared/stdio.ts +++ b/packages/shared/src/shared/stdio.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../types/types.js'; /** * Buffers a continuous stdio stream into discrete JSON-RPC messages. diff --git a/packages/shared/test/.gitkeep b/packages/shared/test/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/shared/test/shared/auth-utils.test.ts b/packages/shared/test/shared/auth-utils.test.ts new file mode 100644 index 000000000..b3b13a2f6 --- /dev/null +++ b/packages/shared/test/shared/auth-utils.test.ts @@ -0,0 +1,90 @@ +import { resourceUrlFromServerUrl, checkResourceAllowed } from '../../src/shared/auth-utils.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/shared/test/shared/auth.test.ts b/packages/shared/test/shared/auth.test.ts new file mode 100644 index 000000000..c4ecab59d --- /dev/null +++ b/packages/shared/test/shared/auth.test.ts @@ -0,0 +1,122 @@ +import { + SafeUrlSchema, + OAuthMetadataSchema, + OpenIdProviderMetadataSchema, + OAuthClientMetadataSchema, + OptionalSafeUrlSchema +} 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/shared/test/shared/protocol-transport-handling.test.ts b/packages/shared/test/shared/protocol-transport-handling.test.ts new file mode 100644 index 000000000..67e749fdf --- /dev/null +++ b/packages/shared/test/shared/protocol-transport-handling.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test, beforeEach } from 'vitest'; +import { Protocol } from '../../src/shared/protocol.js'; +import { Transport } from '../../src/shared/transport.js'; +import { Request, Notification, Result, JSONRPCMessage } from '../../src/types/types.js'; +import * as z from 'zod/v4'; + +// 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 {} + })(); + + 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: Result) => void; + const handlerPromise = new Promise(resolve => { + resolveHandler = resolve; + }); + + const TestRequestSchema = z.object({ + method: z.literal('test/method'), + params: z + .object({ + from: z.string() + }) + .optional() + }); + + protocol.setRequestHandler(TestRequestSchema, async request => { + console.log(`Processing request from ${request.params?.from}`); + return handlerPromise; + }); + + // Client A connects and sends a request + await protocol.connect(transportA); + + const requestFromA = { + jsonrpc: '2.0' as const, + method: 'test/method', + params: { from: 'clientA' }, + id: 1 + }; + + // Simulate client A sending a request + transportA.onmessage?.(requestFromA); + + // While A's request is being processed, client B connects + // This overwrites the transport reference in the protocol + await protocol.connect(transportB); + + const requestFromB = { + jsonrpc: '2.0' as const, + method: 'test/method', + params: { from: 'clientB' }, + id: 2 + }; + + // Client B sends its own request + transportB.onmessage?.(requestFromB); + + // Now complete A's request + resolveHandler!({ data: 'responseForA' } as Result); + + // 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); + + // FIXED: Each transport now receives its own response + + // Transport A should receive response for request ID 1 + expect(transportA.sentMessages.length).toBe(1); + expect(transportA.sentMessages[0]).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { data: 'responseForA' } + }); + + // Transport B should only receive its own response (when implemented) + expect(transportB.sentMessages.length).toBe(1); + expect(transportB.sentMessages[0]).toMatchObject({ + jsonrpc: '2.0', + id: 2, + result: { data: 'responseForA' } // Same handler result in this test + }); + }); + + test('demonstrates the timing issue with multiple rapid connections', async () => { + const delays: number[] = []; + const results: { transport: string; response: JSONRPCMessage[] }[] = []; + + const DelayedRequestSchema = z.object({ + method: z.literal('test/delayed'), + params: z + .object({ + delay: z.number(), + client: z.string() + }) + .optional() + }); + + // Set up handler with variable delay + protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { + const delay = request.params?.delay || 0; + delays.push(delay); + + await new Promise(resolve => setTimeout(resolve, delay)); + + return { + processedBy: `handler-${extra.requestId}`, + delay: delay + } as Result; + }); + + // Rapid succession of connections and requests + await protocol.connect(transportA); + transportA.onmessage?.({ + jsonrpc: '2.0' as const, + method: 'test/delayed', + params: { delay: 50, client: 'A' }, + id: 1 + }); + + // Connect B while A is processing + setTimeout(async () => { + await protocol.connect(transportB); + transportB.onmessage?.({ + jsonrpc: '2.0' as const, + method: 'test/delayed', + params: { delay: 10, client: 'B' }, + 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); + + // FIXED: Each transport receives its own responses + expect(transportA.sentMessages.length).toBe(1); + expect(transportB.sentMessages.length).toBe(1); + }); +}); diff --git a/packages/shared/test/shared/protocol.test.ts b/packages/shared/test/shared/protocol.test.ts new file mode 100644 index 000000000..3b7125466 --- /dev/null +++ b/packages/shared/test/shared/protocol.test.ts @@ -0,0 +1,5558 @@ +import { ZodType, z } from 'zod'; +import { + CallToolRequestSchema, + ClientCapabilities, + ErrorCode, + JSONRPCMessage, + McpError, + RELATED_TASK_META_KEY, + RequestId, + ServerCapabilities, + Task, + TaskCreationParams, + type Request, + type Notification, + type Result +} from '../../src/types/types.js'; +import { Protocol, mergeCapabilities } from '../../src/shared/protocol.js'; +import { Transport, TransportSendOptions } from '../../src/shared/transport.js'; +import { TaskStore, TaskMessageQueue, QueuedMessage, QueuedNotification, QueuedRequest } from '../../src/experimental/tasks/interfaces.js'; +import { MockInstance, vi } from 'vitest'; +import { JSONRPCResultResponse, JSONRPCRequest, JSONRPCErrorResponse } from '../../src/types/types.js'; +import { ErrorMessage, ResponseMessage, toArrayAsync } from '../../src/shared/responseMessage.js'; +import { InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js'; + +// Type helper for accessing private/protected Protocol properties in tests +interface TestProtocol { + _taskMessageQueue?: TaskMessageQueue; + _requestResolvers: Map void>; + _responseHandlers: Map void>; + _taskProgressTokens: Map; + _clearTaskQueue: (taskId: string, sessionId?: string) => Promise; + requestTaskStore: (request: Request, authInfo: unknown) => TaskStore; + // Protected task methods (exposed for testing) + 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'); +} + +describe('protocol tests', () => { + let protocol: Protocol; + let transport: MockTransport; + let sendSpy: MockInstance; + + beforeEach(() => { + transport = new MockTransport(); + sendSpy = vi.spyOn(transport, 'send'); + protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + }); + + 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 protocol.request(request, mockSchema, { + timeout: 0 + }); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + if (error instanceof McpError) { + expect(error.code).toBe(ErrorCode.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 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 protocol + .request(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 protocol + .request(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 protocol.request(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 protocol + .request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 protocol + .request(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 protocol + .request(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 = protocol.request(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 protocol + .request(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 TestProtocol)._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 TestProtocol)._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 protocol + .request(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 TestProtocol)._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 be handled by tool implementors, not protocol layer', () => { + // Task status management is now the responsibility of tool implementors + expect(true).toBe(true); + }); + + 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ taskStore: mockTaskStore }); + + await protocol.connect(transport); + + protocol.setRequestHandler(CallToolRequestSchema, async request => { + // Tool implementor can access task creation parameters from request.params.task + expect(request.params.task).toEqual({ + ttl: 60000, + pollInterval: 1000 + }); + return { result: '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('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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 TestProtocol).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 TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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(CallToolRequestSchema, async (_request, extra) => { + // Send a notification using the extra.sendNotification + await extra.sendNotification({ + 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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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; + const TestRequestSchema = z.object({ + method: z.literal('test/longRunning'), + params: z.optional(z.record(z.unknown())) + }); + protocol.setRequestHandler(TestRequestSchema, async (_request, extra) => { + // Simulate a long-running operation + await new Promise(resolve => setTimeout(resolve, 100)); + wasAborted = extra.signal.aborted; + return { _meta: {} } as Result; + }); + + // Simulate an incoming request + const requestId = 123; + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: requestId, + method: 'test/longRunning', + 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: ErrorCode.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: ErrorCode.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; + const TestMethodSchema = z.object({ + method: z.literal('test/method'), + params: z.optional(z.record(z.unknown())) + }); + protocol.setRequestHandler(TestMethodSchema, async () => { + await new Promise(resolve => setTimeout(resolve, 50)); + requestCompleted = true; + return { _meta: {} } as Result; + }); + + // Create a task + const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { + method: 'test/method', + params: {} + }); + + // Start a request + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 123, + method: 'test/method', + 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + }); + + it('should maintain progress token association after CreateTaskResult is returned', async () => { + const taskStore = createMockTaskStore(); + const protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 protocol + .request(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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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(CallToolRequestSchema, async (request, extra) => { + if (extra.taskStore) { + const task = await extra.taskStore.createTask({ ttl: 60000 }); + + // Simulate async work then complete the task + setTimeout(async () => { + await extra.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 protocol + .request(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 TestProtocol)._taskProgressTokens as Map; + expect(taskProgressTokens.has(taskId)).toBe(true); + expect(taskProgressTokens.get(taskId)).toBe(progressToken); + + // Simulate task completion by calling through the protocol's task store + // This will trigger the cleanup logic + const mockRequest = { jsonrpc: '2.0' as const, id: 999, method: 'test', params: {} }; + const requestTaskStore = (protocol as unknown as TestProtocol).requestTaskStore(mockRequest, undefined); + await requestTaskStore.storeTaskResult(taskId, 'completed', { content: [] }); + + // 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 protocol.request(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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 protocol.request(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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 protocol.request(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 protocol.request(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 protocol.request(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 protocol.request(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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = server.request( + { + 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 TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); + + await server.connect(transport); + + // Send a request without related task metadata + const requestPromise = server.request( + { + method: 'ping', + params: {} + }, + z.object({}) + ); + + // Verify queue exists (but we don't track size in the new API) + const queue = (server as unknown as TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = server.request( + { + method: 'ping', + params: {} + }, + z.object({}), + { + relatedTask: { taskId: task.taskId } + } + ); + + // Verify the resolver was stored + const resolvers = (server as unknown as TestProtocol)._requestResolvers; + expect(resolvers.size).toBe(1); + + // Get the request ID from the queue + const queue = (server as unknown as TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = server.request( + { + 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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 server.request( + { + 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 TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ 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 = server + .request( + { + 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( + server.request( + { + 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 TestProtocol)._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 = protocol.request( + { + 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 TestProtocol)._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 TestProtocol)._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 + const TestRequestSchema = z.object({ + method: z.literal('test/taskRequest'), + params: z + .object({ + _meta: z.optional(z.record(z.unknown())) + }) + .passthrough() + }); + + protocol.setRequestHandler(TestRequestSchema, async () => { + return { content: 'test result' } as Result; + }); + + // Simulate an incoming request with relatedTask metadata + const requestId = 456; + const taskId = 'task-response-test'; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'test/taskRequest', + 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 TestProtocol)._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({ content: 'test result' }); + } + }); + + 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 + const TestRequestSchema = z.object({ + method: z.literal('test/taskRequestError'), + params: z + .object({ + _meta: z.optional(z.record(z.unknown())) + }) + .passthrough() + }); + + protocol.setRequestHandler(TestRequestSchema, async () => { + throw new McpError(ErrorCode.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: 'test/taskRequestError', + 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 TestProtocol)._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(ErrorCode.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 TestProtocol)._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(ErrorCode.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 + const TestRequestSchema = z.object({ + method: z.literal('test/normalRequest'), + params: z.optional(z.record(z.unknown())) + }); + + protocol.setRequestHandler(TestRequestSchema, async () => { + return { content: 'normal result' } as Result; + }); + + // Simulate an incoming request WITHOUT relatedTask metadata + const requestId = 202; + transport.onmessage?.({ + jsonrpc: '2.0', + id: requestId, + method: 'test/normalRequest', + params: {} + }); + + // 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: 'normal result' } + }) + ); + }); + }); + + 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 TestProtocol)._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 = protocol.request( + { + 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 TestProtocol)._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 TestProtocol)._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 = protocol.request( + { + 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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 = protocol.request( + { + method: 'test/request', + params: { data: 'test' } + }, + mockSchema, + { relatedTask } + ); + + const queue = (protocol as unknown as TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ 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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 = protocol + .request({ 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 TestProtocol)._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; + expect(result).toBeInstanceOf(McpError); + 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 TestProtocol)._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 TestProtocol)._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 = protocol + .request({ 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 TestProtocol)._taskMessageQueue; + expect(queue).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocol)._clearTaskQueue(taskId); + + // Verify the request promise is rejected + const result = await requestPromise; + expect(result).toBeInstanceOf(McpError); + 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 = protocol + .request({ method: 'test/request1', params: { data: 'test1' } }, z.object({ result: z.string() }), { + relatedTask: { taskId } + }) + .catch(err => err); + + const request2Promise = protocol + .request({ method: 'test/request2', params: { data: 'test2' } }, z.object({ result: z.string() }), { + relatedTask: { taskId } + }) + .catch(err => err); + + const request3Promise = protocol + .request({ 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 TestProtocol)._taskMessageQueue; + expect(queue).toBeDefined(); + + // Directly call the cleanup method (simulating what happens when task reaches terminal status) + (protocol as unknown as TestProtocol)._clearTaskQueue(taskId); + + // Verify all request promises are rejected + const result1 = await request1Promise; + const result2 = await request2Promise; + const result3 = await request3Promise; + + expect(result1).toBeInstanceOf(McpError); + expect(result1.message).toContain('Task cancelled or completed'); + expect(result2).toBeInstanceOf(McpError); + expect(result2.message).toContain('Task cancelled or completed'); + expect(result3).toBeInstanceOf(McpError); + 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 = protocol + .request({ 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 TestProtocol)._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 TestProtocol)._clearTaskQueue(taskId); + + // Verify request promise is rejected + const result = await requestPromise; + expect(result).toBeInstanceOf(McpError); + 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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + // Start the request stream + const streamPromise = (async () => { + const messages = []; + const stream = (protocol as unknown as TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + // Start the request stream + const streamPromise = (async () => { + const messages = []; + const stream = (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + 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 TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocol).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.code).toBe(ErrorCode.RequestTimeout); + } finally { + vi.useRealTimers(); + } + }); + + test('should yield error as terminal message for cancellation', async () => { + const transport = new MockTransport(); + const protocol = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + const abortController = new AbortController(); + abortController.abort('User cancelled'); + + // Collect messages + const messages = await toArrayAsync( + (protocol as unknown as TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })({ taskStore: mockTaskStore }); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + 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 TestProtocol).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 = new (class extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected assertTaskCapability(): void {} + protected assertTaskHandlerCapability(): void {} + })(); + await protocol.connect(transport); + + const messagesPromise = toArrayAsync( + (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { + protected assertCapabilityForMethod(_method: string): void {} + protected assertNotificationCapability(_method: string): void {} + protected assertRequestHandlerCapability(_method: string): void {} + protected assertTaskCapability(_method: string): void {} + protected assertTaskHandlerCapability(_method: string): void {} + })({ + 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 TestProtocol; + + // 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._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 TestProtocol; + await testProtocol._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 TestProtocol; + testProtocol._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._clearTaskQueue(task.taskId); + + // Verify resolver was called with cancellation error + expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); + + // Verify the error has the correct properties + const calledError = resolverMock.mock.calls[0][0]; + expect(calledError.code).toBe(ErrorCode.InternalError); + expect(calledError.message).toContain('Task cancelled or completed'); + + // Verify resolver was removed + expect(testProtocol._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 TestProtocol; + + // Enqueue multiple messages: request with resolver, request without, notification + const requestId1 = 42; + const resolverMock = vi.fn(); + testProtocol._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._clearTaskQueue(task.taskId); + + // Verify resolver was called for first request + expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); + + // Verify the error has the correct properties + const calledError = resolverMock.mock.calls[0][0]; + expect(calledError.code).toBe(ErrorCode.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 TestProtocol; + 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 TestProtocol; + const responseMessage = msg.message as JSONRPCResultResponse; + const requestId = responseMessage.id as RequestId; + const resolver = testProtocol._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 TestProtocol; + + // This should not throw + await expect(testProtocol._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 TestProtocol; + testProtocol._requestResolvers.set(requestId, resolverMock); + + // Enqueue an error message + await taskMessageQueue.enqueue(task.taskId, { + type: 'error', + message: { + jsonrpc: '2.0', + id: requestId, + error: { + code: ErrorCode.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._requestResolvers.get(reqId); + + if (resolver) { + testProtocol._requestResolvers.delete(reqId); + const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + + // Verify resolver was called with McpError + expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); + const calledError = resolverMock.mock.calls[0][0]; + expect(calledError.code).toBe(ErrorCode.InvalidRequest); + expect(calledError.message).toContain('Invalid request parameters'); + + // Verify resolver was removed from map + expect(testProtocol._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: ErrorCode.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 TestProtocol; + const errorMessage = queuedMessage.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._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 TestProtocol; + testProtocol._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: ErrorCode.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._requestResolvers.get(reqId); + + if (resolver) { + testProtocol._requestResolvers.delete(reqId); + const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + + // Verify resolver was called with McpError including data + expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); + const calledError = resolverMock.mock.calls[0][0]; + expect(calledError.code).toBe(ErrorCode.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: ErrorCode.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 TestProtocol; + const errorMessage = msg.message as JSONRPCErrorResponse; + const requestId = errorMessage.id as RequestId; + const resolver = testProtocol._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 TestProtocol; + + // Set up resolvers for multiple requests + const resolver1 = vi.fn(); + const resolver2 = vi.fn(); + const resolver3 = vi.fn(); + + testProtocol._requestResolvers.set(1, resolver1); + testProtocol._requestResolvers.set(2, resolver2); + testProtocol._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: ErrorCode.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._requestResolvers.get(requestId); + if (resolver) { + testProtocol._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._requestResolvers.get(requestId); + if (resolver) { + testProtocol._requestResolvers.delete(requestId); + const error = new McpError(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(McpError)); + expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); + + // Verify error has correct properties + const error = resolver2.mock.calls[0][0]; + expect(error.code).toBe(ErrorCode.InvalidRequest); + expect(error.message).toContain('Request failed'); + + // Verify all resolvers were removed + expect(testProtocol._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 TestProtocol; + + 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._requestResolvers.set(1, resolver1); + testProtocol._requestResolvers.set(2, resolver2); + testProtocol._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._requestResolvers.get(requestId); + if (resolver) { + testProtocol._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._requestResolvers.get(requestId); + if (resolver) { + testProtocol._requestResolvers.delete(requestId); + const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); + resolver(error); + } + } + } + + // Verify FIFO order was maintained + expect(callOrder).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/packages/shared/test/shared/stdio.test.ts b/packages/shared/test/shared/stdio.test.ts new file mode 100644 index 000000000..a01f770db --- /dev/null +++ b/packages/shared/test/shared/stdio.test.ts @@ -0,0 +1,35 @@ +import { JSONRPCMessage } from '../../src/types/types.js'; +import { ReadBuffer } from '../../src/shared/stdio.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); +}); diff --git a/packages/shared/test/shared/toolNameValidation.test.ts b/packages/shared/test/shared/toolNameValidation.test.ts new file mode 100644 index 000000000..bd3c5ea4f --- /dev/null +++ b/packages/shared/test/shared/toolNameValidation.test.ts @@ -0,0 +1,128 @@ +import { validateToolName, validateAndWarnToolName, issueToolNameWarning } from '../../src/shared/toolNameValidation.js'; +import { vi, MockInstance } from 'vitest'; + +// 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/shared/test/shared/uriTemplate.test.ts b/packages/shared/test/shared/uriTemplate.test.ts new file mode 100644 index 000000000..ec913c0db --- /dev/null +++ b/packages/shared/test/shared/uriTemplate.test.ts @@ -0,0 +1,288 @@ +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(100000); + 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(100000); + 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(10000).fill('{param}').join(''); + expect(() => new UriTemplate(expressions)).not.toThrow(); + }); + + it('should handle maximum variable name length', () => { + const longName = 'a'.repeat(10000); + const template = new UriTemplate(`{${longName}}`); + const vars: Record = {}; + vars[longName] = 'value'; + expect(() => template.expand(vars)).not.toThrow(); + }); + }); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index 30f53d66c..9253559a0 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -4,5 +4,9 @@ "exclude": ["node_modules", "dist"], "compilerOptions": { "baseUrl": ".", + "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/shared/vitest.setup.ts b/packages/shared/vitest.setup.ts new file mode 100644 index 000000000..2c6606b9c --- /dev/null +++ b/packages/shared/vitest.setup.ts @@ -0,0 +1,3 @@ +import '../../vitest.setup'; + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e12aa9d0c..849b432cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config '@modelcontextprotocol/tsconfig': specifier: workspace:^ version: link:../../common/tsconfig @@ -307,9 +310,15 @@ importers: specifier: workspace:^ version: link:../server 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 packages/server: dependencies: @@ -368,6 +377,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config '@modelcontextprotocol/tsconfig': specifier: workspace:^ version: link:../../common/tsconfig @@ -492,6 +504,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config From 617f14e5f896487ba3a66f1824ec75dc69317b2f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 10:12:23 +0200 Subject: [PATCH 07/22] save commit; successful tests; successful checks --- common/eslint-config/eslint.config.mjs | 1 - common/eslint-config/eslint.config.ts | 34 - common/eslint-config/package.json | 1 - common/eslint-config/tsconfig.json | 8 - common/tsconfig/tsconfig.json | 17 +- common/vitest-config/package.json | 3 +- common/vitest-config/tsconfig.json | 8 - .../{vitest.config.mjs => vitest.config.js} | 3 - common/vitest-config/vitest.setup.ts | 8 - package.json | 19 +- packages/client/eslint.config.mjs | 2 - packages/client/package.json | 2 +- packages/client/src/client/auth-extensions.ts | 2 +- packages/client/src/client/auth.ts | 23 +- packages/client/src/client/client.ts | 18 +- packages/client/src/client/middleware.ts | 2 +- packages/client/src/client/sse.ts | 4 +- packages/client/src/client/stdio.ts | 6 +- packages/client/src/client/streamableHttp.ts | 10 +- packages/client/src/client/websocket.ts | 4 +- .../client/src/experimental/tasks/client.ts | 14 +- packages/client/src/index.ts | 2 +- .../test/client/auth-extensions.test.ts | 2 +- packages/client/test/client/auth.test.ts | 130 +- .../client/test/client/cross-spawn.test.ts | 2 +- .../client/test/client/middleware.test.ts | 38 +- packages/client/test/client/sse.test.ts | 22 +- packages/client/test/client/stdio.test.ts | 6 +- .../client/test/client/streamableHttp.test.ts | 26 +- .../test/experimental/tasks/task.test.ts | 117 - .../client/test}/helpers/http.ts | 0 packages/client/test/helpers/mcp.ts | 71 + .../client/test}/helpers/oauth.ts | 5 +- packages/client/tsconfig.json | 16 +- packages/client/vitest.config.js | 22 + packages/client/vitest.setup.ts | 2 - packages/{shared => core}/eslint.config.mjs | 2 - packages/{shared => core}/package.json | 9 +- packages/{shared => core}/src/auth/errors.ts | 36 +- .../src/experimental/index.ts | 2 +- .../src/experimental/tasks/helpers.ts | 0 .../src/experimental/tasks/interfaces.ts | 0 .../experimental/tasks/stores/in-memory.ts | 2 +- packages/core/src/exports/types/index.ts | 1 + packages/{shared => core}/src/index.ts | 1 - .../{shared => core}/src/shared/auth-utils.ts | 0 packages/{shared => core}/src/shared/auth.ts | 0 .../core/src}/shared/metadataUtils.ts | 2 +- .../{shared => core}/src/shared/protocol.ts | 0 .../core/src}/shared/responseMessage.ts | 2 +- packages/{shared => core}/src/shared/stdio.ts | 0 .../src/shared/toolNameValidation.ts | 0 .../{shared => core}/src/shared/transport.ts | 0 .../core/src}/shared/uriTemplate.ts | 8 +- packages/core/src/types/spec.types.ts | 2552 ++++++++ packages/{shared => core}/src/types/types.ts | 2 +- .../{shared => core}/src/util/inMemory.ts | 0 .../{shared => core}/src/util/zod-compat.ts | 0 .../src/util/zod-json-schema-compat.ts | 0 .../src/validation/ajv-provider.ts | 0 .../src/validation/cfworker-provider.ts | 0 .../{shared => core}/src/validation/types.ts | 0 {test => packages/core/test}/inMemory.test.ts | 6 +- .../test/shared/auth-utils.test.ts | 0 .../{shared => core}/test/shared/auth.test.ts | 0 .../protocol-transport-handling.test.ts | 0 .../test/shared/protocol.test.ts | 94 +- .../test/shared/stdio.test.ts | 0 .../test/shared/toolNameValidation.test.ts | 0 .../test/shared/uriTemplate.test.ts | 0 .../core/test}/spec.types.test.ts | 14 +- .../core/test}/types.capabilities.test.ts | 2 +- {test => packages/core/test}/types.test.ts | 12 +- .../core/test}/validation/validation.test.ts | 0 packages/core/tsconfig.json | 18 + .../vitest.config.js} | 0 packages/examples/eslint.config.mjs | 13 +- packages/examples/package.json | 4 +- .../src/client/elicitationUrlExample.ts | 2 +- .../src/client/simpleStreamableHttp.ts | 8 +- .../src/client/simpleTaskInteractiveClient.ts | 4 +- .../src/server/elicitationFormExample.ts | 2 +- .../src/server/elicitationUrlExample.ts | 10 +- .../examples/src/server/simpleSseServer.ts | 2 +- .../src/server/simpleStreamableHttp.ts | 2 +- .../src/server/simpleTaskInteractive.ts | 6 +- .../sseAndStreamableHttpCompatibleServer.ts | 2 +- .../examples/src/shared/inMemoryEventStore.ts | 6 +- .../server/demoInMemoryOAuthProvider.test.ts | 10 +- packages/examples/tsconfig.json | 15 +- packages/examples/vitest.config.js | 18 + packages/integration/eslint.config.mjs | 5 + packages/integration/package.json | 65 + .../test}/__fixtures__/serverThatHangs.ts | 4 +- .../test}/__fixtures__/testServer.ts | 4 +- .../test}/__fixtures__/zodTestMatrix.ts | 0 .../test/client/client.test.ts} | 106 +- .../experimental/tasks/task-listing.test.ts | 6 +- .../test}/experimental/tasks/task.test.ts | 4 +- packages/integration/test/helpers/http.ts | 96 + .../integration/test}/helpers/mcp.ts | 10 +- packages/integration/test/helpers/oauth.ts | 88 + .../integration/test}/helpers/tasks.ts | 2 +- .../integration/test}/processCleanup.test.ts | 15 +- .../integration/test/server.test.ts | 28 +- .../test}/server/elicitation.test.ts | 12 +- .../integration/test}/server/mcp.test.ts | 202 +- .../stateManagementStreamableHttp.test.ts | 14 +- .../integration/test}/taskLifecycle.test.ts | 44 +- .../test}/taskResumability.test.ts | 16 +- .../integration/test}/title.test.ts | 52 +- packages/integration/tsconfig.json | 14 + packages/integration/vitest.config.js | 18 + packages/server/eslint.config.mjs | 2 - packages/server/package.json | 2 +- .../server/src/experimental/tasks/index.ts | 3 - .../src/experimental/tasks/interfaces.ts | 13 +- .../src/experimental/tasks/mcp-server.ts | 6 +- .../server/src/experimental/tasks/server.ts | 16 +- packages/server/src/index.ts | 3 +- packages/server/src/server/auth/clients.ts | 2 +- .../src/server/auth/handlers/authorize.ts | 8 +- .../src/server/auth/handlers/metadata.ts | 2 +- .../src/server/auth/handlers/register.ts | 4 +- .../server/src/server/auth/handlers/revoke.ts | 4 +- .../server/src/server/auth/handlers/token.ts | 2 +- packages/server/src/server/auth/index.ts | 2 +- .../server/auth/middleware/allowedMethods.ts | 2 +- .../src/server/auth/middleware/bearerAuth.ts | 6 +- .../src/server/auth/middleware/clientAuth.ts | 4 +- packages/server/src/server/auth/provider.ts | 4 +- .../server/auth/providers/proxyProvider.ts | 8 +- packages/server/src/server/auth/router.ts | 2 +- packages/server/src/server/completable.ts | 2 +- .../server/src/server}/inMemoryEventStore.ts | 6 +- packages/server/src/server/mcp.ts | 12 +- packages/server/src/server/server.ts | 22 +- packages/server/src/server/sse.ts | 6 +- packages/server/src/server/stdio.ts | 6 +- packages/server/src/server/streamableHttp.ts | 6 +- packages/server/test/.gitkeep | 0 .../test/server/__fixtures__/zodTestMatrix.ts | 22 + .../server/auth/handlers/authorize.test.ts | 22 +- .../server/auth/handlers/metadata.test.ts | 2 +- .../server/auth/handlers/register.test.ts | 2 +- .../test}/server/auth/handlers/revoke.test.ts | 6 +- .../test}/server/auth/handlers/token.test.ts | 6 +- .../auth/middleware/allowedMethods.test.ts | 0 .../server/auth/middleware/bearerAuth.test.ts | 6 +- .../server/auth/middleware/clientAuth.test.ts | 2 +- .../auth/providers/proxyProvider.test.ts | 12 +- .../server/test}/server/auth/router.test.ts | 8 +- .../server/test}/server/completable.test.ts | 2 +- .../server/test}/server/sse.test.ts | 8 +- .../server/test}/server/stdio.test.ts | 8 +- .../test}/server/streamableHttp.test.ts | 22 +- packages/server/tsconfig.json | 7 +- packages/server/vitest.config.js | 16 + packages/server/vitest.config.ts | 3 - packages/server/vitest.setup.ts | 3 - packages/shared/src/shared/metadataUtils.ts | 26 - packages/shared/src/shared/responseMessage.ts | 70 - packages/shared/src/shared/uriTemplate.ts | 287 - packages/shared/src/types/spec.types.ts | 2587 -------- packages/shared/tsconfig.json | 12 - packages/shared/vitest.config.ts | 3 - packages/shared/vitest.setup.ts | 3 - pnpm-lock.yaml | 76 +- pnpm-workspace.yaml | 2 +- src/__mocks__/pkce-challenge.ts | 6 - src/client/auth-extensions.ts | 401 -- src/client/auth.ts | 1298 ---- src/client/index.ts | 905 --- src/client/middleware.ts | 320 - src/client/sse.ts | 296 - src/client/stdio.ts | 263 - src/client/streamableHttp.ts | 674 -- src/client/websocket.ts | 74 - src/examples/README.md | 352 -- src/examples/client/elicitationUrlExample.ts | 791 --- .../client/multipleClientsParallel.ts | 154 - .../client/parallelToolCallsClient.ts | 196 - .../client/simpleClientCredentials.ts | 82 - src/examples/client/simpleOAuthClient.ts | 458 -- .../client/simpleOAuthClientProvider.ts | 66 - src/examples/client/simpleStreamableHttp.ts | 924 --- .../client/simpleTaskInteractiveClient.ts | 204 - src/examples/client/ssePollingClient.ts | 106 - .../streamableHttpWithSseFallbackClient.ts | 191 - .../server/README-simpleTaskInteractive.md | 161 - .../server/demoInMemoryOAuthProvider.ts | 249 - src/examples/server/elicitationFormExample.ts | 471 -- src/examples/server/elicitationUrlExample.ts | 771 --- .../server/jsonResponseStreamableHttp.ts | 177 - src/examples/server/mcpServerOutputSchema.ts | 80 - src/examples/server/simpleSseServer.ts | 174 - .../server/simpleStatelessStreamableHttp.ts | 171 - src/examples/server/simpleStreamableHttp.ts | 751 --- src/examples/server/simpleTaskInteractive.ts | 745 --- .../sseAndStreamableHttpCompatibleServer.ts | 251 - src/examples/server/ssePollingExample.ts | 151 - .../standaloneSseWithGetStreamableHttp.ts | 127 - src/examples/server/toolWithSampleServer.ts | 57 - src/experimental/index.ts | 13 - src/experimental/tasks/client.ts | 264 - src/experimental/tasks/helpers.ts | 88 - src/experimental/tasks/index.ts | 34 - src/experimental/tasks/interfaces.ts | 289 - src/experimental/tasks/mcp-server.ts | 142 - src/experimental/tasks/server.ts | 131 - src/experimental/tasks/stores/in-memory.ts | 295 - src/experimental/tasks/types.ts | 43 - src/inMemory.ts | 63 - src/server/auth/clients.ts | 22 - src/server/auth/errors.ts | 212 - src/server/auth/handlers/authorize.ts | 165 - src/server/auth/handlers/metadata.ts | 19 - src/server/auth/handlers/register.ts | 119 - src/server/auth/handlers/revoke.ts | 79 - src/server/auth/handlers/token.ts | 155 - src/server/auth/middleware/allowedMethods.ts | 20 - src/server/auth/middleware/bearerAuth.ts | 102 - src/server/auth/middleware/clientAuth.ts | 64 - src/server/auth/provider.ts | 83 - src/server/auth/providers/proxyProvider.ts | 238 - src/server/auth/router.ts | 240 - src/server/auth/types.ts | 36 - src/server/completable.ts | 67 - src/server/express.ts | 74 - src/server/index.ts | 669 -- src/server/mcp.ts | 1542 ----- src/server/middleware/hostHeaderValidation.ts | 79 - src/server/sse.ts | 220 - src/server/stdio.ts | 92 - src/server/streamableHttp.ts | 969 --- src/server/zod-compat.ts | 280 - src/server/zod-json-schema-compat.ts | 68 - src/shared/auth-utils.ts | 55 - src/shared/auth.ts | 231 - src/shared/protocol.ts | 1657 ----- src/shared/stdio.ts | 39 - src/shared/toolNameValidation.ts | 115 - src/shared/transport.ts | 128 - src/spec.types.ts | 2587 -------- src/types.ts | 2556 -------- src/validation/ajv-provider.ts | 97 - src/validation/cfworker-provider.ts | 77 - src/validation/index.ts | 30 - src/validation/types.ts | 63 - test/client/auth-extensions.test.ts | 331 - test/client/auth.test.ts | 3247 ---------- test/client/cross-spawn.test.ts | 153 - test/client/index.test.ts | 4139 ------------ test/client/middleware.test.ts | 1118 ---- test/client/sse.test.ts | 1506 ----- test/client/stdio.test.ts | 77 - test/client/streamableHttp.test.ts | 1626 ----- .../tasks/stores/in-memory.test.ts | 936 --- test/experimental/tasks/task-listing.test.ts | 128 - test/shared/auth-utils.test.ts | 90 - test/shared/auth.test.ts | 122 - .../protocol-transport-handling.test.ts | 189 - test/shared/protocol.test.ts | 5558 ----------------- test/shared/stdio.test.ts | 35 - test/shared/toolNameValidation.test.ts | 128 - test/shared/uriTemplate.test.ts | 288 - vitest.workspace.js | 3 + 267 files changed, 3803 insertions(+), 50281 deletions(-) delete mode 100644 common/eslint-config/eslint.config.ts delete mode 100644 common/eslint-config/tsconfig.json delete mode 100644 common/vitest-config/tsconfig.json rename common/vitest-config/{vitest.config.mjs => vitest.config.js} (80%) delete mode 100644 common/vitest-config/vitest.setup.ts delete mode 100644 packages/client/test/experimental/tasks/task.test.ts rename {test => packages/client/test}/helpers/http.ts (100%) create mode 100644 packages/client/test/helpers/mcp.ts rename {test => packages/client/test}/helpers/oauth.ts (95%) create mode 100644 packages/client/vitest.config.js rename packages/{shared => core}/eslint.config.mjs (98%) rename packages/{shared => core}/package.json (93%) rename packages/{shared => core}/src/auth/errors.ts (85%) rename packages/{shared => core}/src/experimental/index.ts (62%) rename packages/{shared => core}/src/experimental/tasks/helpers.ts (100%) rename packages/{shared => core}/src/experimental/tasks/interfaces.ts (100%) rename packages/{shared => core}/src/experimental/tasks/stores/in-memory.ts (99%) create mode 100644 packages/core/src/exports/types/index.ts rename packages/{shared => core}/src/index.ts (99%) rename packages/{shared => core}/src/shared/auth-utils.ts (100%) rename packages/{shared => core}/src/shared/auth.ts (100%) rename {src => packages/core/src}/shared/metadataUtils.ts (94%) rename packages/{shared => core}/src/shared/protocol.ts (100%) rename {src => packages/core/src}/shared/responseMessage.ts (96%) rename packages/{shared => core}/src/shared/stdio.ts (100%) rename packages/{shared => core}/src/shared/toolNameValidation.ts (100%) rename packages/{shared => core}/src/shared/transport.ts (100%) rename {src => packages/core/src}/shared/uriTemplate.ts (98%) create mode 100644 packages/core/src/types/spec.types.ts rename packages/{shared => core}/src/types/types.ts (99%) rename packages/{shared => core}/src/util/inMemory.ts (100%) rename packages/{shared => core}/src/util/zod-compat.ts (100%) rename packages/{shared => core}/src/util/zod-json-schema-compat.ts (100%) rename packages/{shared => core}/src/validation/ajv-provider.ts (100%) rename packages/{shared => core}/src/validation/cfworker-provider.ts (100%) rename packages/{shared => core}/src/validation/types.ts (100%) rename {test => packages/core/test}/inMemory.test.ts (95%) rename packages/{shared => core}/test/shared/auth-utils.test.ts (100%) rename packages/{shared => core}/test/shared/auth.test.ts (100%) rename packages/{shared => core}/test/shared/protocol-transport-handling.test.ts (100%) rename packages/{shared => core}/test/shared/protocol.test.ts (98%) rename packages/{shared => core}/test/shared/stdio.test.ts (100%) rename packages/{shared => core}/test/shared/toolNameValidation.test.ts (100%) rename packages/{shared => core}/test/shared/uriTemplate.test.ts (100%) rename {test => packages/core/test}/spec.types.test.ts (98%) rename {test => packages/core/test}/types.capabilities.test.ts (99%) rename {test => packages/core/test}/types.test.ts (98%) rename {test => packages/core/test}/validation/validation.test.ts (100%) create mode 100644 packages/core/tsconfig.json rename packages/{client/vitest.config.ts => core/vitest.config.js} (100%) rename {test/examples => packages/examples/test}/server/demoInMemoryOAuthProvider.test.ts (96%) create mode 100644 packages/examples/vitest.config.js create mode 100644 packages/integration/eslint.config.mjs create mode 100644 packages/integration/package.json rename {src => packages/integration/test}/__fixtures__/serverThatHangs.ts (88%) rename {src => packages/integration/test}/__fixtures__/testServer.ts (68%) rename {src => packages/integration/test}/__fixtures__/zodTestMatrix.ts (100%) rename packages/{client/test/client/index.test.ts => integration/test/client/client.test.ts} (97%) rename packages/{client => integration}/test/experimental/tasks/task-listing.test.ts (96%) rename {test => packages/integration/test}/experimental/tasks/task.test.ts (96%) create mode 100644 packages/integration/test/helpers/http.ts rename {test => packages/integration/test}/helpers/mcp.ts (81%) create mode 100644 packages/integration/test/helpers/oauth.ts rename {test => packages/integration/test}/helpers/tasks.ts (93%) rename {test/integration-tests => packages/integration/test}/processCleanup.test.ts (87%) rename test/server/index.test.ts => packages/integration/test/server.test.ts (99%) rename {test => packages/integration/test}/server/elicitation.test.ts (98%) rename {test => packages/integration/test}/server/mcp.test.ts (97%) rename {test/integration-tests => packages/integration/test}/stateManagementStreamableHttp.test.ts (96%) rename {test/integration-tests => packages/integration/test}/taskLifecycle.test.ts (97%) rename {test/integration-tests => packages/integration/test}/taskResumability.test.ts (95%) rename {test/server => packages/integration/test}/title.test.ts (80%) create mode 100644 packages/integration/tsconfig.json create mode 100644 packages/integration/vitest.config.js rename {src/examples/shared => packages/server/src/server}/inMemoryEventStore.ts (93%) delete mode 100644 packages/server/test/.gitkeep create mode 100644 packages/server/test/server/__fixtures__/zodTestMatrix.ts rename {test => packages/server/test}/server/auth/handlers/authorize.test.ts (93%) rename {test => packages/server/test}/server/auth/handlers/metadata.test.ts (97%) rename {test => packages/server/test}/server/auth/handlers/register.test.ts (99%) rename {test => packages/server/test}/server/auth/handlers/revoke.test.ts (97%) rename {test => packages/server/test}/server/auth/handlers/token.test.ts (98%) rename {test => packages/server/test}/server/auth/middleware/allowedMethods.test.ts (100%) rename {test => packages/server/test}/server/auth/middleware/bearerAuth.test.ts (99%) rename {test => packages/server/test}/server/auth/middleware/clientAuth.test.ts (98%) rename {test => packages/server/test}/server/auth/providers/proxyProvider.test.ts (96%) rename {test => packages/server/test}/server/auth/router.test.ts (98%) rename {test => packages/server/test}/server/completable.test.ts (95%) rename {test => packages/server/test}/server/sse.test.ts (99%) rename {test => packages/server/test}/server/stdio.test.ts (90%) rename {test => packages/server/test}/server/streamableHttp.test.ts (99%) create mode 100644 packages/server/vitest.config.js delete mode 100644 packages/server/vitest.config.ts delete mode 100644 packages/server/vitest.setup.ts delete mode 100644 packages/shared/src/shared/metadataUtils.ts delete mode 100644 packages/shared/src/shared/responseMessage.ts delete mode 100644 packages/shared/src/shared/uriTemplate.ts delete mode 100644 packages/shared/src/types/spec.types.ts delete mode 100644 packages/shared/tsconfig.json delete mode 100644 packages/shared/vitest.config.ts delete mode 100644 packages/shared/vitest.setup.ts delete mode 100644 src/__mocks__/pkce-challenge.ts delete mode 100644 src/client/auth-extensions.ts delete mode 100644 src/client/auth.ts delete mode 100644 src/client/index.ts delete mode 100644 src/client/middleware.ts delete mode 100644 src/client/sse.ts delete mode 100644 src/client/stdio.ts delete mode 100644 src/client/streamableHttp.ts delete mode 100644 src/client/websocket.ts delete mode 100644 src/examples/README.md delete mode 100644 src/examples/client/elicitationUrlExample.ts delete mode 100644 src/examples/client/multipleClientsParallel.ts delete mode 100644 src/examples/client/parallelToolCallsClient.ts delete mode 100644 src/examples/client/simpleClientCredentials.ts delete mode 100644 src/examples/client/simpleOAuthClient.ts delete mode 100644 src/examples/client/simpleOAuthClientProvider.ts delete mode 100644 src/examples/client/simpleStreamableHttp.ts delete mode 100644 src/examples/client/simpleTaskInteractiveClient.ts delete mode 100644 src/examples/client/ssePollingClient.ts delete mode 100644 src/examples/client/streamableHttpWithSseFallbackClient.ts delete mode 100644 src/examples/server/README-simpleTaskInteractive.md delete mode 100644 src/examples/server/demoInMemoryOAuthProvider.ts delete mode 100644 src/examples/server/elicitationFormExample.ts delete mode 100644 src/examples/server/elicitationUrlExample.ts delete mode 100644 src/examples/server/jsonResponseStreamableHttp.ts delete mode 100644 src/examples/server/mcpServerOutputSchema.ts delete mode 100644 src/examples/server/simpleSseServer.ts delete mode 100644 src/examples/server/simpleStatelessStreamableHttp.ts delete mode 100644 src/examples/server/simpleStreamableHttp.ts delete mode 100644 src/examples/server/simpleTaskInteractive.ts delete mode 100644 src/examples/server/sseAndStreamableHttpCompatibleServer.ts delete mode 100644 src/examples/server/ssePollingExample.ts delete mode 100644 src/examples/server/standaloneSseWithGetStreamableHttp.ts delete mode 100644 src/examples/server/toolWithSampleServer.ts delete mode 100644 src/experimental/index.ts delete mode 100644 src/experimental/tasks/client.ts delete mode 100644 src/experimental/tasks/helpers.ts delete mode 100644 src/experimental/tasks/index.ts delete mode 100644 src/experimental/tasks/interfaces.ts delete mode 100644 src/experimental/tasks/mcp-server.ts delete mode 100644 src/experimental/tasks/server.ts delete mode 100644 src/experimental/tasks/stores/in-memory.ts delete mode 100644 src/experimental/tasks/types.ts delete mode 100644 src/inMemory.ts delete mode 100644 src/server/auth/clients.ts delete mode 100644 src/server/auth/errors.ts delete mode 100644 src/server/auth/handlers/authorize.ts delete mode 100644 src/server/auth/handlers/metadata.ts delete mode 100644 src/server/auth/handlers/register.ts delete mode 100644 src/server/auth/handlers/revoke.ts delete mode 100644 src/server/auth/handlers/token.ts delete mode 100644 src/server/auth/middleware/allowedMethods.ts delete mode 100644 src/server/auth/middleware/bearerAuth.ts delete mode 100644 src/server/auth/middleware/clientAuth.ts delete mode 100644 src/server/auth/provider.ts delete mode 100644 src/server/auth/providers/proxyProvider.ts delete mode 100644 src/server/auth/router.ts delete mode 100644 src/server/auth/types.ts delete mode 100644 src/server/completable.ts delete mode 100644 src/server/express.ts delete mode 100644 src/server/index.ts delete mode 100644 src/server/mcp.ts delete mode 100644 src/server/middleware/hostHeaderValidation.ts delete mode 100644 src/server/sse.ts delete mode 100644 src/server/stdio.ts delete mode 100644 src/server/streamableHttp.ts delete mode 100644 src/server/zod-compat.ts delete mode 100644 src/server/zod-json-schema-compat.ts delete mode 100644 src/shared/auth-utils.ts delete mode 100644 src/shared/auth.ts delete mode 100644 src/shared/protocol.ts delete mode 100644 src/shared/stdio.ts delete mode 100644 src/shared/toolNameValidation.ts delete mode 100644 src/shared/transport.ts delete mode 100644 src/spec.types.ts delete mode 100644 src/types.ts delete mode 100644 src/validation/ajv-provider.ts delete mode 100644 src/validation/cfworker-provider.ts delete mode 100644 src/validation/index.ts delete mode 100644 src/validation/types.ts delete mode 100644 test/client/auth-extensions.test.ts delete mode 100644 test/client/auth.test.ts delete mode 100644 test/client/cross-spawn.test.ts delete mode 100644 test/client/index.test.ts delete mode 100644 test/client/middleware.test.ts delete mode 100644 test/client/sse.test.ts delete mode 100644 test/client/stdio.test.ts delete mode 100644 test/client/streamableHttp.test.ts delete mode 100644 test/experimental/tasks/stores/in-memory.test.ts delete mode 100644 test/experimental/tasks/task-listing.test.ts delete mode 100644 test/shared/auth-utils.test.ts delete mode 100644 test/shared/auth.test.ts delete mode 100644 test/shared/protocol-transport-handling.test.ts delete mode 100644 test/shared/protocol.test.ts delete mode 100644 test/shared/stdio.test.ts delete mode 100644 test/shared/toolNameValidation.test.ts delete mode 100644 test/shared/uriTemplate.test.ts create mode 100644 vitest.workspace.js diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index d30cd8208..f2c30824d 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -43,4 +43,3 @@ export default tseslint.config( }, eslintConfigPrettier ); - diff --git a/common/eslint-config/eslint.config.ts b/common/eslint-config/eslint.config.ts deleted file mode 100644 index f976b1b0a..000000000 --- a/common/eslint-config/eslint.config.ts +++ /dev/null @@ -1,34 +0,0 @@ -// @ts-check - -import * as eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import * as eslintConfigPrettier from 'eslint-config-prettier/flat'; -import * as nodePlugin from 'eslint-plugin-n'; - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - { - linterOptions: { - reportUnusedDisableDirectives: false - }, - plugins: { - n: nodePlugin - }, - rules: { - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'n/prefer-node-protocol': 'error' - } - }, - { - ignores: ['src/spec.types.ts'] - }, - { - files: ['src/client/**/*.ts', 'src/server/**/*.ts'], - ignores: ['**/*.test.ts'], - rules: { - 'no-console': 'error' - } - }, - eslintConfigPrettier -); diff --git a/common/eslint-config/package.json b/common/eslint-config/package.json index df5606aac..93de60e6d 100644 --- a/common/eslint-config/package.json +++ b/common/eslint-config/package.json @@ -22,7 +22,6 @@ }, "version": "2.0.0", "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-n": "^17.23.1", diff --git a/common/eslint-config/tsconfig.json b/common/eslint-config/tsconfig.json deleted file mode 100644 index 32203633b..000000000 --- a/common/eslint-config/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/common/tsconfig/tsconfig.json b/common/tsconfig/tsconfig.json index a2ad603c4..7a40758bc 100644 --- a/common/tsconfig/tsconfig.json +++ b/common/tsconfig/tsconfig.json @@ -1,8 +1,17 @@ { "compilerOptions": { - "target": "es2018", - "module": "Node16", - "moduleResolution": "Node16", + "target": "esnext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowSyntheticDefaultImports": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "libReplacement": false, + "noImplicitReturns": true, + "incremental": true, "declaration": true, "declarationMap": true, "sourceMap": true, @@ -18,4 +27,4 @@ }, "types": ["node", "vitest/globals"] } -} \ No newline at end of file +} diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json index 3a11922b3..b244e0773 100644 --- a/common/vitest-config/package.json +++ b/common/vitest-config/package.json @@ -4,8 +4,7 @@ "main": "vitest.config.mjs", "type": "module", "exports": { - ".": "./vitest.config.mjs", - "./tsconfig.json": "./tsconfig.json" + ".": "./vitest.config.js" }, "dependencies": { "typescript": "catalog:" diff --git a/common/vitest-config/tsconfig.json b/common/vitest-config/tsconfig.json deleted file mode 100644 index 32203633b..000000000 --- a/common/vitest-config/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/common/vitest-config/vitest.config.mjs b/common/vitest-config/vitest.config.js similarity index 80% rename from common/vitest-config/vitest.config.mjs rename to common/vitest-config/vitest.config.js index 55f1b6aca..b7b0410e9 100644 --- a/common/vitest-config/vitest.config.mjs +++ b/common/vitest-config/vitest.config.js @@ -4,9 +4,6 @@ export default defineConfig({ test: { globals: true, environment: 'node', - setupFiles: ['./vitest.setup.ts'], include: ['test/**/*.test.ts'] } }); - - diff --git a/common/vitest-config/vitest.setup.ts b/common/vitest-config/vitest.setup.ts deleted file mode 100644 index 820dcbd89..000000000 --- a/common/vitest-config/vitest.setup.ts +++ /dev/null @@ -1,8 +0,0 @@ -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') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).crypto = webcrypto as unknown as Crypto; -} diff --git a/package.json b/package.json index bfbc73802..37c6118ca 100644 --- a/package.json +++ b/package.json @@ -69,21 +69,12 @@ "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "typecheck": "tsgo --noEmit", - "build": "npm run build:esm && npm run build:cjs", - "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", - "build:esm:w": "npm run build:esm -- -w", - "build:cjs": "mkdir -p dist/cjs && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && tsc -p tsconfig.cjs.json", - "build:cjs:w": "npm run build:cjs -- -w", - "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", - "prepack": "npm run build:esm && npm run build:cjs", - "lint": "eslint src/ && prettier --check .", + "build:all": "pnpm -r build", + "prepack:all": "pnpm -r prepack", + "lint:all": "pnpm -r lint", "lint:fix": "eslint src/ --fix && prettier --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" + "check:all": "pnpm -r check", + "test:all": "pnpm -r test" }, "dependencies": { "ajv": "^8.17.1", diff --git a/packages/client/eslint.config.mjs b/packages/client/eslint.config.mjs index 70e926598..951c9f3a9 100644 --- a/packages/client/eslint.config.mjs +++ b/packages/client/eslint.config.mjs @@ -3,5 +3,3 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; export default baseConfig; - - diff --git a/packages/client/package.json b/packages/client/package.json index 4332bcebc..5f169ed23 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -51,7 +51,7 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/shared": "workspace:^", + "@modelcontextprotocol/sdk-core": "workspace:^", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts index 56ffd3929..69dcbaf9e 100644 --- a/packages/client/src/client/auth-extensions.ts +++ b/packages/client/src/client/auth-extensions.ts @@ -6,7 +6,7 @@ */ import type { JWK } from 'jose'; -import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/shared'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../../../core/src/index.js'; import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index d8400a66e..61a97d44b 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,5 +1,5 @@ import pkceChallenge from 'pkce-challenge'; -import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/shared'; +import { LATEST_PROTOCOL_VERSION } from '../../../core/src/index.js'; import { OAuthClientMetadata, OAuthClientInformation, @@ -11,14 +11,14 @@ import { OAuthErrorResponseSchema, AuthorizationServerMetadata, OpenIdProviderDiscoveryMetadataSchema -} from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema -} from '@modelcontextprotocol/shared'; -import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../../core/src/index.js'; import { InvalidClientError, InvalidClientMetadataError, @@ -27,8 +27,8 @@ import { OAuthError, ServerError, UnauthorizedClientError -} from '@modelcontextprotocol/shared'; -import { FetchLike } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import { FetchLike } from '../../../core/src/index.js'; /** * Function type for adding client authentication to token requests. @@ -574,7 +574,7 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU } const [type, scheme] = authenticateHeader.split(' '); - if (type.toLowerCase() !== 'bearer' || !scheme) { + if (type?.toLowerCase() !== 'bearer' || !scheme) { return {}; } @@ -617,7 +617,10 @@ function extractFieldFromWwwAuth(response: Response, fieldName: string): string if (match) { // Pattern matches: field_name="value" or field_name=value (unquoted) - return match[1] || match[2]; + const result = match[1] || match[2]; + if (result) { + return result; + } } return null; @@ -634,13 +637,13 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { } const [type, scheme] = authenticateHeader.split(' '); - if (type.toLowerCase() !== 'bearer' || !scheme) { + if (type?.toLowerCase() !== 'bearer' || !scheme) { return undefined; } const regex = /resource_metadata="([^"]*)"/; const match = regex.exec(authenticateHeader); - if (!match) { + if (!match || !match[1]) { return undefined; } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 41fd728ce..100e279f8 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,5 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/shared'; -import type { Transport } from '@modelcontextprotocol/shared'; +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../../../core/src/index.js'; +import type { Transport } from '../../../core/src/index.js'; import { type CallToolRequest, @@ -49,9 +49,9 @@ import { type Request, type Notification, type Result -} from '@modelcontextprotocol/shared'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/shared'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import { AjvJsonSchemaValidator } from '../../../core/src/index.js'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../../../core/src/index.js'; import { AnyObjectSchema, SchemaOutput, @@ -60,10 +60,10 @@ import { safeParse, type ZodV3Internal, type ZodV4Internal -} from '@modelcontextprotocol/shared'; -import type { RequestHandlerExtra } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import type { RequestHandlerExtra } from '../../../core/src/index.js'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/shared'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../../../core/src/index.js'; /** * Elicitation default application helper. Applies defaults to the data based on the schema. @@ -79,7 +79,7 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn const obj = data as Record; const props = schema.properties as Record; for (const key of Object.keys(props)) { - const propSchema = props[key]; + 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; diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index e0847ca7a..f4ae00806 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,5 +1,5 @@ import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { FetchLike } from '@modelcontextprotocol/shared'; +import { FetchLike } from '../../../core/src/index.js'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 9d177900e..5095c8b3b 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,6 +1,6 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/shared'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../../../core/src/index.js'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../../../core/src/index.js'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; export class SseError extends Error { diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index dbccebf20..2a9686831 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -2,9 +2,9 @@ import { ChildProcess, IOType } from 'node:child_process'; import spawn from 'cross-spawn'; import process from 'node:process'; import { Stream, PassThrough } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/shared'; -import { Transport } from '@modelcontextprotocol/shared'; -import { JSONRPCMessage } from '@modelcontextprotocol/shared'; +import { ReadBuffer, serializeMessage } from '../../../core/src/index.js'; +import { Transport } from '../../../core/src/index.js'; +import { JSONRPCMessage } from '../../../core/src/index.js'; export type StdioServerParameters = { /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 8943270c8..ceeecd236 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,5 +1,11 @@ -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/shared'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../../../core/src/index.js'; +import { + isInitializedNotification, + isJSONRPCRequest, + isJSONRPCResultResponse, + JSONRPCMessage, + JSONRPCMessageSchema +} from '../../../core/src/index.js'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index be2e2c2b6..b31382a3e 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,5 +1,5 @@ -import { Transport } from '@modelcontextprotocol/shared'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/shared'; +import { Transport } from '../../../core/src/index.js'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '../../../core/src/index.js'; const SUBPROTOCOL = 'mcp'; diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 0f3f37118..5a7f304a3 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -5,14 +5,14 @@ * @experimental */ -import type { Client } from '../../client/index.js'; -import type { RequestOptions } from '@modelcontextprotocol/shared'; -import type { ResponseMessage } from '@modelcontextprotocol/shared'; -import type { AnyObjectSchema, SchemaOutput } from '@modelcontextprotocol/shared'; -import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '@modelcontextprotocol/shared'; -import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/shared'; +import type { Client } from '../../client/client.js'; +import type { RequestOptions } from '../../../../core/src/index.js'; +import type { ResponseMessage } from '../../../../core/src/index.js'; +import type { AnyObjectSchema, SchemaOutput } from '../../../../core/src/index.js'; +import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '../../../../core/src/index.js'; +import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '../../../../core/src/index.js'; -import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/shared'; +import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '../../../../core/src/index.js'; /** * Internal interface for accessing Client's private methods. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 5bd2e8360..286e2f50a 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -11,4 +11,4 @@ export * from './client/websocket.js'; export * from './experimental/index.js'; // re-export shared types -export * from '@modelcontextprotocol/shared'; \ No newline at end of file +export * from '../../core/src/index.js'; diff --git a/packages/client/test/client/auth-extensions.test.ts b/packages/client/test/client/auth-extensions.test.ts index a7217307d..0487a76be 100644 --- a/packages/client/test/client/auth-extensions.test.ts +++ b/packages/client/test/client/auth-extensions.test.ts @@ -274,7 +274,7 @@ describe('createPrivateKeyJwtAuth', () => { 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()); + const payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString()); expect(payload.aud).toBe('https://issuer.example.com'); }); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index d6e7e8684..c1c815207 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -1,4 +1,10 @@ -import { LATEST_PROTOCOL_VERSION } from '../../src/types.js'; +import { + LATEST_PROTOCOL_VERSION, + InvalidClientMetadataError, + ServerError, + AuthorizationServerMetadata, + OAuthTokens +} from '@modelcontextprotocol/sdk-core'; import { discoverOAuthMetadata, discoverAuthorizationServerMetadata, @@ -15,8 +21,6 @@ import { isHttpsUrl } from '../../src/client/auth.js'; import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; -import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; -import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; // Mock pkce-challenge @@ -125,7 +129,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); }); @@ -159,7 +163,7 @@ describe('OAuth Authorization', () => { expect(mockFetch).toHaveBeenCalledTimes(2); // Verify first call had MCP header - expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version'); + expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); }); it('throws an error when all fetch attempts fail', async () => { @@ -230,7 +234,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path/name'); }); @@ -245,7 +249,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource/path?param=value'); }); @@ -272,14 +276,14 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(2); // First call should be path-aware - const [firstUrl, firstOptions] = calls[0]; + 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]; + 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 @@ -335,7 +339,7 @@ describe('OAuth Authorization', () => { const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); }); @@ -353,7 +357,7 @@ describe('OAuth Authorization', () => { const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); }); @@ -381,7 +385,7 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(3); // Final call should be root fallback - const [lastUrl, lastOptions] = calls[2]; + 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 @@ -404,7 +408,7 @@ describe('OAuth Authorization', () => { const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://custom.example.com/metadata'); }); @@ -426,7 +430,7 @@ describe('OAuth Authorization', () => { expect(customFetch).toHaveBeenCalledTimes(1); expect(mockFetch).not.toHaveBeenCalled(); - const [url, options] = customFetch.mock.calls[0]; + 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 @@ -455,7 +459,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); - const [url, options] = calls[0]; + 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 @@ -473,7 +477,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validMetadata); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); - const [url, options] = calls[0]; + 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 @@ -501,14 +505,14 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(2); // First call should be path-aware - const [firstUrl, firstOptions] = calls[0]; + 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]; + 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 @@ -548,7 +552,7 @@ describe('OAuth Authorization', () => { const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); }); @@ -565,7 +569,7 @@ describe('OAuth Authorization', () => { const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback - const [url] = calls[0]; + const [url] = calls[0]!; expect(url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); }); @@ -593,7 +597,7 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(3); // Final call should be root fallback - const [lastUrl, lastOptions] = calls[2]; + 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 @@ -630,7 +634,7 @@ describe('OAuth Authorization', () => { expect(mockFetch).toHaveBeenCalledTimes(2); // Verify first call had MCP header - expect(mockFetch.mock.calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version'); + expect(mockFetch.mock.calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); }); it('throws an error when all fetch attempts fail', async () => { @@ -722,7 +726,7 @@ describe('OAuth Authorization', () => { expect(customFetch).toHaveBeenCalledTimes(1); expect(mockFetch).not.toHaveBeenCalled(); - const [url, options] = customFetch.mock.calls[0]; + 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 @@ -771,7 +775,7 @@ describe('OAuth Authorization', () => { 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'); + expect(urls[0]!.url.toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server/tenant1'); }); }); @@ -817,8 +821,8 @@ describe('OAuth Authorization', () => { // 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'); + 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 () => { @@ -865,10 +869,10 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(2); // First call should have headers - expect(calls[0][1]?.headers).toHaveProperty('MCP-Protocol-Version'); + expect(calls[0]![1]?.headers).toHaveProperty('MCP-Protocol-Version'); // Second call should not have headers (CORS retry) - expect(calls[1][1]?.headers).toBeUndefined(); + expect(calls[1]![1]?.headers).toBeUndefined(); }); it('supports custom fetch function', async () => { @@ -896,7 +900,7 @@ describe('OAuth Authorization', () => { expect(metadata).toEqual(validOAuthMetadata); const calls = mockFetch.mock.calls; - const [, options] = calls[0]; + const [, options] = calls[0]!; expect(options.headers).toEqual({ 'MCP-Protocol-Version': '2025-01-01', Accept: 'application/json' @@ -1140,7 +1144,7 @@ describe('OAuth Authorization', () => { }) ); - const options = mockFetch.mock.calls[0][1]; + 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); @@ -1180,7 +1184,7 @@ describe('OAuth Authorization', () => { }) ); - const options = mockFetch.mock.calls[0][1]; + const options = mockFetch.mock.calls[0]![1]; expect(options.headers).toBeInstanceOf(Headers); expect(options.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); @@ -1229,10 +1233,10 @@ describe('OAuth Authorization', () => { }) ); - const headers = mockFetch.mock.calls[0][1].headers as Headers; + 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; + 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'); @@ -1297,7 +1301,7 @@ describe('OAuth Authorization', () => { expect(customFetch).toHaveBeenCalledTimes(1); expect(mockFetch).not.toHaveBeenCalled(); - const [url, options] = customFetch.mock.calls[0]; + const [url, options] = customFetch.mock.calls[0]!; expect(url.toString()).toBe('https://auth.example.com/token'); expect(options).toEqual( expect.objectContaining({ @@ -1366,9 +1370,9 @@ describe('OAuth Authorization', () => { }) ); - const headers = mockFetch.mock.calls[0][1].headers as Headers; + 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; + 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')).toBe('client123'); @@ -1410,10 +1414,10 @@ describe('OAuth Authorization', () => { }) ); - const headers = mockFetch.mock.calls[0][1].headers as Headers; + 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; + 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(); @@ -1740,10 +1744,10 @@ describe('OAuth Authorization', () => { 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'); + 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'); + 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 () => { @@ -1869,7 +1873,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; + 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'); }); @@ -2126,7 +2130,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; + 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/'); @@ -2184,7 +2188,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; + 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); @@ -2379,9 +2383,9 @@ describe('OAuth Authorization', () => { expect(result).toBe('REDIRECT'); // Verify the authorization URL includes the scopes from PRM - const redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]; + 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'); + expect(authUrl?.searchParams.get('scope')).toBe('mcp:read mcp:write mcp:admin'); }); it('prefers explicit scope parameter over scopes_supported from PRM', async () => { @@ -2444,7 +2448,7 @@ describe('OAuth Authorization', () => { 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 redirectCall = (mockProvider.redirectToAuthorization as Mock).mock.calls[0]!; const authUrl: URL = redirectCall[0]; expect(authUrl.searchParams.get('scope')).toBe('mcp:read'); }); @@ -2501,10 +2505,10 @@ describe('OAuth Authorization', () => { 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'); + 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'); + 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 () => { @@ -2565,10 +2569,10 @@ describe('OAuth Authorization', () => { 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'); + 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'); + expect(customFetch.mock.calls[1]![0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); }); }); @@ -2627,7 +2631,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check Authorization header const authHeader = request.headers.get('Authorization'); @@ -2655,7 +2659,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check no Authorization header expect(request.headers.get('Authorization')).toBeNull(); @@ -2681,7 +2685,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + 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'); @@ -2716,7 +2720,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check no Authorization header expect(request.headers.get('Authorization')).toBeNull(); @@ -2741,7 +2745,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check headers expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); @@ -2795,7 +2799,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check Authorization header const authHeader = request.headers.get('Authorization'); @@ -2822,7 +2826,7 @@ describe('OAuth Authorization', () => { }); expect(tokens).toEqual(validTokens); - const request = mockFetch.mock.calls[0][1]; + const request = mockFetch.mock.calls[0]![1]; // Check no Authorization header expect(request.headers.get('Authorization')).toBeNull(); @@ -2836,7 +2840,7 @@ describe('OAuth Authorization', () => { describe('RequestInit headers passthrough', () => { it('custom headers from RequestInit are passed to auth discovery requests', async () => { - const { createFetchWithInit } = await import('../../src/shared/transport.js'); + const { createFetchWithInit } = await import('@modelcontextprotocol/sdk-core'); const customFetch = vi.fn().mockResolvedValue({ ok: true, @@ -2858,7 +2862,7 @@ describe('OAuth Authorization', () => { await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); expect(customFetch).toHaveBeenCalledTimes(1); - const [url, options] = customFetch.mock.calls[0]; + const [url, options] = customFetch.mock.calls[0]!; expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); expect(options.headers).toMatchObject({ @@ -2869,7 +2873,7 @@ describe('OAuth Authorization', () => { }); it('auth-specific headers override base headers from RequestInit', async () => { - const { createFetchWithInit } = await import('../../src/shared/transport.js'); + const { createFetchWithInit } = await import('@modelcontextprotocol/sdk-core'); const customFetch = vi.fn().mockResolvedValue({ ok: true, @@ -2896,7 +2900,7 @@ describe('OAuth Authorization', () => { }); expect(customFetch).toHaveBeenCalled(); - const [, options] = customFetch.mock.calls[0]; + const [, options] = customFetch.mock.calls[0]!; // Auth-specific Accept header should override base Accept header expect(options.headers).toMatchObject({ @@ -2907,7 +2911,7 @@ describe('OAuth Authorization', () => { }); it('other RequestInit options are passed through', async () => { - const { createFetchWithInit } = await import('../../src/shared/transport.js'); + const { createFetchWithInit } = await import('@modelcontextprotocol/sdk-core'); const customFetch = vi.fn().mockResolvedValue({ ok: true, @@ -2931,7 +2935,7 @@ describe('OAuth Authorization', () => { await discoverOAuthProtectedResourceMetadata('https://resource.example.com', undefined, wrappedFetch); expect(customFetch).toHaveBeenCalledTimes(1); - const [, options] = customFetch.mock.calls[0]; + const [, options] = customFetch.mock.calls[0]!; // All RequestInit options should be preserved expect(options.credentials).toBe('include'); diff --git a/packages/client/test/client/cross-spawn.test.ts b/packages/client/test/client/cross-spawn.test.ts index 26ae682fe..136330ecd 100644 --- a/packages/client/test/client/cross-spawn.test.ts +++ b/packages/client/test/client/cross-spawn.test.ts @@ -1,6 +1,6 @@ import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/stdio.js'; import spawn from 'cross-spawn'; -import { JSONRPCMessage } from '../../src/types.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import { ChildProcess } from 'node:child_process'; import { Mock, MockedFunction } from 'vitest'; diff --git a/packages/client/test/client/middleware.test.ts b/packages/client/test/client/middleware.test.ts index 06bda69c8..ab017b638 100644 --- a/packages/client/test/client/middleware.test.ts +++ b/packages/client/test/client/middleware.test.ts @@ -1,6 +1,6 @@ import { withOAuth, withLogging, applyMiddlewares, createMiddleware } from '../../src/client/middleware.js'; import { OAuthClientProvider } from '../../src/client/auth.js'; -import { FetchLike } from '../../src/shared/transport.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk-core'; import { MockInstance, Mocked, MockedFunction } from 'vitest'; vi.mock('../../src/client/auth.js', async () => { @@ -64,7 +64,7 @@ describe('withOAuth', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Bearer test-token'); }); @@ -90,7 +90,7 @@ describe('withOAuth', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Bearer test-token'); }); @@ -105,7 +105,7 @@ describe('withOAuth', () => { expect(mockFetch).toHaveBeenCalledTimes(1); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBeNull(); }); @@ -152,7 +152,7 @@ describe('withOAuth', () => { // Verify the retry used the new token const retryCallArgs = mockFetch.mock.calls[1]; - const retryHeaders = retryCallArgs[1]?.headers as Headers; + const retryHeaders = retryCallArgs![1]?.headers as Headers; expect(retryHeaders.get('Authorization')).toBe('Bearer new-token'); }); @@ -200,7 +200,7 @@ describe('withOAuth', () => { // Verify the retry used the new token const retryCallArgs = mockFetch.mock.calls[1]; - const retryHeaders = retryCallArgs[1]?.headers as Headers; + const retryHeaders = retryCallArgs![1]?.headers as Headers; expect(retryHeaders.get('Authorization')).toBe('Bearer new-token'); }); @@ -290,7 +290,7 @@ describe('withOAuth', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Content-Type')).toBe('application/json'); expect(headers.get('Authorization')).toBe('Bearer test-token'); }); @@ -492,7 +492,7 @@ describe('withLogging', () => { responseHeaders: undefined }); - const logCall = mockLogger.mock.calls[0][0]; + const logCall = mockLogger.mock.calls[0]![0]; expect(logCall.requestHeaders?.get('Authorization')).toBe('Bearer token'); expect(logCall.requestHeaders?.get('Content-Type')).toBe('application/json'); }); @@ -515,7 +515,7 @@ describe('withLogging', () => { await enhancedFetch('https://api.example.com/data'); - const logCall = mockLogger.mock.calls[0][0]; + 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'); }); @@ -609,7 +609,7 @@ describe('withLogging', () => { await enhancedFetch('https://api.example.com/data'); - const logCall = mockLogger.mock.calls[0][0]; + const logCall = mockLogger.mock.calls[0]![0]; expect(logCall.duration).toBeGreaterThanOrEqual(90); // Allow some margin for timing }); }); @@ -654,7 +654,7 @@ describe('applyMiddleware', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('X-Middleware-1')).toBe('applied'); }); @@ -686,7 +686,7 @@ describe('applyMiddleware', () => { await composedFetch('https://api.example.com/data'); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + 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'); @@ -711,7 +711,7 @@ describe('applyMiddleware', () => { // Should have both Authorization header and logging const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Bearer test-token'); expect(mockLogger).toHaveBeenCalledWith({ method: 'GET', @@ -811,7 +811,7 @@ describe('Integration Tests', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Bearer sse-token'); expect(headers.get('Content-Type')).toBe('application/json'); }); @@ -857,7 +857,7 @@ describe('Integration Tests', () => { }); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + 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'); }); @@ -943,7 +943,7 @@ describe('createMiddleware', () => { ); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('X-Custom-Header')).toBe('custom-value'); }); @@ -969,13 +969,13 @@ describe('createMiddleware', () => { // Test API route await enhancedFetch('https://example.com/api/users'); let callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + 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; + const maybeHeaders = callArgs![1]?.headers as Headers | undefined; expect(maybeHeaders?.get('X-API-Version')).toBeUndefined(); }); @@ -1091,7 +1091,7 @@ describe('createMiddleware', () => { await enhancedFetch('https://api.example.com/data'); const callArgs = mockFetch.mock.calls[0]; - const headers = callArgs[1]?.headers as Headers; + const headers = callArgs![1]?.headers as Headers; expect(headers.get('Authorization')).toBe('Custom token'); }); diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 6574b60b8..5fc38e5b7 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1,9 +1,13 @@ import { createServer, ServerResponse, type IncomingMessage, type Server } from 'node:http'; -import { JSONRPCMessage } from '../../src/types.js'; +import { + JSONRPCMessage, + InvalidClientError, + InvalidGrantError, + UnauthorizedClientError, + OAuthTokens +} from '@modelcontextprotocol/sdk-core'; import { SSEClientTransport } from '../../src/client/sse.js'; import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; -import { OAuthTokens } from '../../src/shared/auth.js'; -import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; import { listenOnRandomPort } from '../helpers/http.js'; import { AddressInfo } from 'node:net'; @@ -169,7 +173,7 @@ describe('SSEClientTransport', () => { await new Promise(resolve => setTimeout(resolve, 50)); expect(errors).toHaveLength(1); - expect(errors[0].message).toMatch(/JSON/); + expect(errors[0]!.message).toMatch(/JSON/); }); it('handles messages via POST requests', async () => { @@ -310,7 +314,7 @@ describe('SSEClientTransport', () => { await transport.send(message); - const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; + const calledHeaders = (global.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'); @@ -319,7 +323,7 @@ describe('SSEClientTransport', () => { await transport.send(message); - const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; + const updatedHeaders = (global.fetch as Mock).mock.calls[1]![1].headers; expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); } finally { global.fetch = originalFetch; @@ -353,7 +357,7 @@ describe('SSEClientTransport', () => { await transport.send(message); - const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; + const calledHeaders = (global.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'); @@ -362,7 +366,7 @@ describe('SSEClientTransport', () => { await transport.send(message); - const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; + const updatedHeaders = (global.fetch as Mock).mock.calls[1]![1].headers; expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); } finally { global.fetch = originalFetch; @@ -387,7 +391,7 @@ describe('SSEClientTransport', () => { await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); - const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; + const calledHeaders = (global.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'); diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 52a871ee1..470939865 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from '../../src/types.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import { StdioClientTransport, StdioServerParameters } from '../../src/client/stdio.js'; // Configure default server parameters based on OS @@ -59,8 +59,8 @@ test('should read messages', async () => { }); await client.start(); - await client.send(messages[0]); - await client.send(messages[1]); + await client.send(messages[0]!); + await client.send(messages[1]!); await finished; expect(readMessages).toEqual(messages); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 52c8f1074..84fa4358a 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1,7 +1,7 @@ import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js'; import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; -import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js'; -import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; +import { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/sdk-core'; +import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '@modelcontextprotocol/sdk-core'; import { type Mock, type Mocked } from 'vitest'; describe('StreamableHTTPClientTransport', () => { @@ -114,7 +114,7 @@ describe('StreamableHTTPClientTransport', () => { // Check that second request included session ID header const calls = (global.fetch as Mock).mock.calls; - const lastCall = calls[calls.length - 1]; + const lastCall = calls[calls.length - 1]!; expect(lastCall[1].headers).toBeDefined(); expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); }); @@ -151,7 +151,7 @@ describe('StreamableHTTPClientTransport', () => { // Verify the DELETE request was sent with the session ID const calls = (global.fetch as Mock).mock.calls; - const lastCall = calls[calls.length - 1]; + const lastCall = calls[calls.length - 1]!; expect(lastCall[1].method).toBe('DELETE'); expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); @@ -412,7 +412,7 @@ describe('StreamableHTTPClientTransport', () => { // Verify fetch was called with the lastEventId header expect(fetchSpy).toHaveBeenCalled(); - const fetchCall = fetchSpy.mock.calls[0]; + const fetchCall = fetchSpy.mock.calls[0]!; const headers = fetchCall[1].headers; expect(headers.get('last-event-id')).toBe('test-event-id'); }); @@ -772,8 +772,8 @@ describe('StreamableHTTPClientTransport', () => { ); // 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'); + 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 () => { @@ -822,7 +822,7 @@ describe('StreamableHTTPClientTransport', () => { // 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'); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); it('should reconnect a POST-initiated stream after receiving a priming event', async () => { @@ -938,7 +938,7 @@ describe('StreamableHTTPClientTransport', () => { // 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'); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); it('should not attempt reconnection after close() is called', async () => { @@ -989,7 +989,7 @@ describe('StreamableHTTPClientTransport', () => { // 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'); + expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); it('should not throw JSON parse error on priming events with empty data', async () => { @@ -1487,11 +1487,11 @@ describe('StreamableHTTPClientTransport', () => { // 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'); + 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; + const secondCallHeaders = fetchMock.mock.calls[1]![1]?.headers; expect(secondCallHeaders?.get('last-event-id')).toBe('evt-1'); }); }); diff --git a/packages/client/test/experimental/tasks/task.test.ts b/packages/client/test/experimental/tasks/task.test.ts deleted file mode 100644 index 37e3938d2..000000000 --- a/packages/client/test/experimental/tasks/task.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isTerminal } from '../../../src/experimental/tasks/interfaces.js'; -import type { Task } from '../../../src/types.js'; - -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: 60000, - createdAt, - lastUpdatedAt: createdAt, - pollInterval: 1000 - }; - - expect(task.ttl).toBe(60000); - 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: 30000, - 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: 30000, - 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(); - statuses.forEach(status => { - const task: Task = { - taskId: `test-${status}`, - status, - ttl: null, - createdAt, - lastUpdatedAt: createdAt - }; - expect(task.status).toBe(status); - }); - }); -}); diff --git a/test/helpers/http.ts b/packages/client/test/helpers/http.ts similarity index 100% rename from test/helpers/http.ts rename to packages/client/test/helpers/http.ts diff --git a/packages/client/test/helpers/mcp.ts b/packages/client/test/helpers/mcp.ts new file mode 100644 index 000000000..e56ca6b56 --- /dev/null +++ b/packages/client/test/helpers/mcp.ts @@ -0,0 +1,71 @@ +import { InMemoryTransport } from '@modelcontextprotocol/sdk-server'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/sdk-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/helpers/oauth.ts b/packages/client/test/helpers/oauth.ts similarity index 95% rename from test/helpers/oauth.ts rename to packages/client/test/helpers/oauth.ts index c08350eff..b5e504f68 100644 --- a/test/helpers/oauth.ts +++ b/packages/client/test/helpers/oauth.ts @@ -1,4 +1,5 @@ -import type { FetchLike } from '../../src/shared/transport.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk-core'; +import { vi } from 'vitest'; export interface MockOAuthFetchOptions { resourceServerUrl: string; @@ -79,7 +80,7 @@ export function createMockOAuthFetch(options: MockOAuthFetchOptions): FetchLike /** * Helper to install a vi.fn-based global.fetch mock for tests that rely on global fetch. */ -export function mockGlobalFetch() { +export function mockGlobalFetch(): (...args: any[]) => any { const mockFetch = vi.fn(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).fetch = mockFetch; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index e84c3a8fe..59650b684 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,13 +1,19 @@ { "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], + "include": ["./", "../integration/test/index.test.ts"], "exclude": ["node_modules", "dist"], "compilerOptions": { - "baseUrl": ".", + "rootDir": ".", "paths": { - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"], - "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"] + "*": ["./*"], + "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], + "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], + "@modelcontextprotocol/sdk-client": ["./src/index.ts"], + "@modelcontextprotocol/sdk-client/*": ["./src/*"], + "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], + "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], + "@modelcontextprotocol/vitest-config": ["../common/vitest-config/tsconfig.json"], + "@modelcontextprotocol/eslint-config": ["../common/eslint-config/tsconfig.json"] } } } diff --git a/packages/client/vitest.config.js b/packages/client/vitest.config.js new file mode 100644 index 000000000..9dd7945cc --- /dev/null +++ b/packages/client/vitest.config.js @@ -0,0 +1,22 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; +import { mergeConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default mergeConfig(baseConfig, { + test: { + setupFiles: ['./vitest.setup.ts'] + }, + resolve: { + alias: { + // Use workspace source packages instead of built dist/ for tests + '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), + '@modelcontextprotocol/sdk-core/types': path.resolve(__dirname, '../core/src/exports/types/index.ts'), + '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), + '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') + } + } +}); diff --git a/packages/client/vitest.setup.ts b/packages/client/vitest.setup.ts index 2c6606b9c..292abcb9f 100644 --- a/packages/client/vitest.setup.ts +++ b/packages/client/vitest.setup.ts @@ -1,3 +1 @@ import '../../vitest.setup'; - - diff --git a/packages/shared/eslint.config.mjs b/packages/core/eslint.config.mjs similarity index 98% rename from packages/shared/eslint.config.mjs rename to packages/core/eslint.config.mjs index 70e926598..951c9f3a9 100644 --- a/packages/shared/eslint.config.mjs +++ b/packages/core/eslint.config.mjs @@ -3,5 +3,3 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; export default baseConfig; - - diff --git a/packages/shared/package.json b/packages/core/package.json similarity index 93% rename from packages/shared/package.json rename to packages/core/package.json index c6c5896ff..553b93541 100644 --- a/packages/shared/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/shared", + "name": "@modelcontextprotocol/sdk-core", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", @@ -22,6 +22,12 @@ "exports": { ".": { "import": "./dist/index.js" + }, + "./types": { + "import": "./dist/exports/types/index.js" + }, + "./test-helpers": { + "import": "./dist/exports/test-helpers/index.js" } }, "typesVersions": { @@ -52,7 +58,6 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", diff --git a/packages/shared/src/auth/errors.ts b/packages/core/src/auth/errors.ts similarity index 85% rename from packages/shared/src/auth/errors.ts rename to packages/core/src/auth/errors.ts index d77a9a0b1..9d29805fb 100644 --- a/packages/shared/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -41,7 +41,7 @@ export class OAuthError extends Error { * or is otherwise malformed. */ export class InvalidRequestError extends OAuthError { - static errorCode = 'invalid_request'; + static override errorCode = 'invalid_request'; } /** @@ -49,7 +49,7 @@ export class InvalidRequestError extends OAuthError { * authentication included, or unsupported authentication method). */ export class InvalidClientError extends OAuthError { - static errorCode = 'invalid_client'; + static override errorCode = 'invalid_client'; } /** @@ -58,7 +58,7 @@ export class InvalidClientError extends OAuthError { * authorization request, or was issued to another client. */ export class InvalidGrantError extends OAuthError { - static errorCode = 'invalid_grant'; + static override errorCode = 'invalid_grant'; } /** @@ -66,7 +66,7 @@ export class InvalidGrantError extends OAuthError { * this authorization grant type. */ export class UnauthorizedClientError extends OAuthError { - static errorCode = 'unauthorized_client'; + static override errorCode = 'unauthorized_client'; } /** @@ -74,7 +74,7 @@ export class UnauthorizedClientError extends OAuthError { * by the authorization server. */ export class UnsupportedGrantTypeError extends OAuthError { - static errorCode = 'unsupported_grant_type'; + static override errorCode = 'unsupported_grant_type'; } /** @@ -82,14 +82,14 @@ export class UnsupportedGrantTypeError extends OAuthError { * exceeds the scope granted by the resource owner. */ export class InvalidScopeError extends OAuthError { - static errorCode = 'invalid_scope'; + static override errorCode = 'invalid_scope'; } /** * Access denied error - The resource owner or authorization server denied the request. */ export class AccessDeniedError extends OAuthError { - static errorCode = 'access_denied'; + static override errorCode = 'access_denied'; } /** @@ -97,7 +97,7 @@ export class AccessDeniedError extends OAuthError { * that prevented it from fulfilling the request. */ export class ServerError extends OAuthError { - static errorCode = 'server_error'; + static override errorCode = 'server_error'; } /** @@ -105,7 +105,7 @@ export class ServerError extends OAuthError { * handle the request due to a temporary overloading or maintenance of the server. */ export class TemporarilyUnavailableError extends OAuthError { - static errorCode = 'temporarily_unavailable'; + static override errorCode = 'temporarily_unavailable'; } /** @@ -113,7 +113,7 @@ export class TemporarilyUnavailableError extends OAuthError { * obtaining an authorization code using this method. */ export class UnsupportedResponseTypeError extends OAuthError { - static errorCode = 'unsupported_response_type'; + static override errorCode = 'unsupported_response_type'; } /** @@ -121,7 +121,7 @@ export class UnsupportedResponseTypeError extends OAuthError { * the requested token type. */ export class UnsupportedTokenTypeError extends OAuthError { - static errorCode = 'unsupported_token_type'; + static override errorCode = 'unsupported_token_type'; } /** @@ -129,7 +129,7 @@ export class UnsupportedTokenTypeError extends OAuthError { * or invalid for other reasons. */ export class InvalidTokenError extends OAuthError { - static errorCode = 'invalid_token'; + static override errorCode = 'invalid_token'; } /** @@ -137,7 +137,7 @@ export class InvalidTokenError extends OAuthError { * (Custom, non-standard error) */ export class MethodNotAllowedError extends OAuthError { - static errorCode = 'method_not_allowed'; + static override errorCode = 'method_not_allowed'; } /** @@ -145,7 +145,7 @@ export class MethodNotAllowedError extends OAuthError { * (Custom, non-standard error based on RFC 6585) */ export class TooManyRequestsError extends OAuthError { - static errorCode = 'too_many_requests'; + static override errorCode = 'too_many_requests'; } /** @@ -153,14 +153,14 @@ export class TooManyRequestsError extends OAuthError { * (Custom error for dynamic client registration - RFC 7591) */ export class InvalidClientMetadataError extends OAuthError { - static errorCode = 'invalid_client_metadata'; + static override errorCode = 'invalid_client_metadata'; } /** * Insufficient scope error - The request requires higher privileges than provided by the access token. */ export class InsufficientScopeError extends OAuthError { - static errorCode = 'insufficient_scope'; + static override errorCode = 'insufficient_scope'; } /** @@ -168,7 +168,7 @@ export class InsufficientScopeError extends OAuthError { * (Custom error for resource indicators - RFC 8707) */ export class InvalidTargetError extends OAuthError { - static errorCode = 'invalid_target'; + static override errorCode = 'invalid_target'; } /** @@ -183,7 +183,7 @@ export class CustomOAuthError extends OAuthError { super(message, errorUri); } - get errorCode(): string { + override get errorCode(): string { return this.customErrorCode; } } diff --git a/packages/shared/src/experimental/index.ts b/packages/core/src/experimental/index.ts similarity index 62% rename from packages/shared/src/experimental/index.ts rename to packages/core/src/experimental/index.ts index 7ca654d46..1a641c25d 100644 --- a/packages/shared/src/experimental/index.ts +++ b/packages/core/src/experimental/index.ts @@ -1,3 +1,3 @@ export * from './tasks/helpers.js'; export * from './tasks/interfaces.js'; -export * from './tasks/stores/in-memory.js'; \ No newline at end of file +export * from './tasks/stores/in-memory.js'; diff --git a/packages/shared/src/experimental/tasks/helpers.ts b/packages/core/src/experimental/tasks/helpers.ts similarity index 100% rename from packages/shared/src/experimental/tasks/helpers.ts rename to packages/core/src/experimental/tasks/helpers.ts diff --git a/packages/shared/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts similarity index 100% rename from packages/shared/src/experimental/tasks/interfaces.ts rename to packages/core/src/experimental/tasks/interfaces.ts diff --git a/packages/shared/src/experimental/tasks/stores/in-memory.ts b/packages/core/src/experimental/tasks/stores/in-memory.ts similarity index 99% rename from packages/shared/src/experimental/tasks/stores/in-memory.ts rename to packages/core/src/experimental/tasks/stores/in-memory.ts index aff3ad910..8ef54e599 100644 --- a/packages/shared/src/experimental/tasks/stores/in-memory.ts +++ b/packages/core/src/experimental/tasks/stores/in-memory.ts @@ -5,7 +5,7 @@ * @experimental */ -import { Task, RequestId, Result, Request } from '../../../types.js'; +import { Task, RequestId, Result, Request } from '../../../types/types.js'; import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; import { randomBytes } from 'node:crypto'; diff --git a/packages/core/src/exports/types/index.ts b/packages/core/src/exports/types/index.ts new file mode 100644 index 000000000..47806ee8b --- /dev/null +++ b/packages/core/src/exports/types/index.ts @@ -0,0 +1 @@ +export type * from '../../types/types.js'; diff --git a/packages/shared/src/index.ts b/packages/core/src/index.ts similarity index 99% rename from packages/shared/src/index.ts rename to packages/core/src/index.ts index 98955ac81..b7da6aae3 100644 --- a/packages/shared/src/index.ts +++ b/packages/core/src/index.ts @@ -50,4 +50,3 @@ export * from './validation/cfworker-provider.js'; // Core types only - implementations are exported via separate entry points export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './validation/types.js'; - diff --git a/packages/shared/src/shared/auth-utils.ts b/packages/core/src/shared/auth-utils.ts similarity index 100% rename from packages/shared/src/shared/auth-utils.ts rename to packages/core/src/shared/auth-utils.ts diff --git a/packages/shared/src/shared/auth.ts b/packages/core/src/shared/auth.ts similarity index 100% rename from packages/shared/src/shared/auth.ts rename to packages/core/src/shared/auth.ts diff --git a/src/shared/metadataUtils.ts b/packages/core/src/shared/metadataUtils.ts similarity index 94% rename from src/shared/metadataUtils.ts rename to packages/core/src/shared/metadataUtils.ts index 18f84a4c9..1a05c2a8f 100644 --- a/src/shared/metadataUtils.ts +++ b/packages/core/src/shared/metadataUtils.ts @@ -1,4 +1,4 @@ -import { BaseMetadata } from '../types.js'; +import { BaseMetadata } from '../types/types.js'; /** * Utilities for working with BaseMetadata objects. diff --git a/packages/shared/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts similarity index 100% rename from packages/shared/src/shared/protocol.ts rename to packages/core/src/shared/protocol.ts diff --git a/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts similarity index 96% rename from src/shared/responseMessage.ts rename to packages/core/src/shared/responseMessage.ts index 6fefcf1f6..1acd66711 100644 --- a/src/shared/responseMessage.ts +++ b/packages/core/src/shared/responseMessage.ts @@ -1,4 +1,4 @@ -import { Result, Task, McpError } from '../types.js'; +import { Result, Task, McpError } from '../types/types.js'; /** * Base message type diff --git a/packages/shared/src/shared/stdio.ts b/packages/core/src/shared/stdio.ts similarity index 100% rename from packages/shared/src/shared/stdio.ts rename to packages/core/src/shared/stdio.ts diff --git a/packages/shared/src/shared/toolNameValidation.ts b/packages/core/src/shared/toolNameValidation.ts similarity index 100% rename from packages/shared/src/shared/toolNameValidation.ts rename to packages/core/src/shared/toolNameValidation.ts diff --git a/packages/shared/src/shared/transport.ts b/packages/core/src/shared/transport.ts similarity index 100% rename from packages/shared/src/shared/transport.ts rename to packages/core/src/shared/transport.ts diff --git a/src/shared/uriTemplate.ts b/packages/core/src/shared/uriTemplate.ts similarity index 98% rename from src/shared/uriTemplate.ts rename to packages/core/src/shared/uriTemplate.ts index 1dd57f56f..631b65cb0 100644 --- a/src/shared/uriTemplate.ts +++ b/packages/core/src/shared/uriTemplate.ts @@ -65,7 +65,7 @@ export class UriTemplate { const operator = this.getOperator(expr); const exploded = expr.includes('*'); const names = this.getNames(expr); - const name = names[0]; + const name = names[0]!; // Validate variable name length for (const name of names) { @@ -210,7 +210,7 @@ export class UriTemplate { if (part.operator === '?' || part.operator === '&') { for (let i = 0; i < part.names.length; i++) { - const name = part.names[i]; + const name = part.names[i]!; const prefix = i === 0 ? '\\' + part.operator : '&'; patterns.push({ pattern: prefix + this.escapeRegExp(name) + '=([^&]+)', @@ -271,8 +271,8 @@ export class UriTemplate { const result: Variables = {}; for (let i = 0; i < names.length; i++) { - const { name, exploded } = names[i]; - const value = match[i + 1]; + const { name, exploded } = names[i]!; + const value = match[i + 1]!; const cleanName = name.replace('*', ''); if (exploded && value.includes(',')) { diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.ts new file mode 100644 index 000000000..aa298e63c --- /dev/null +++ b/packages/core/src/types/spec.types.ts @@ -0,0 +1,2552 @@ +/** + * 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: 35fa160caf287a9c48696e3ae452c0645c713669 + * + * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. + * To update this file, run: npm run fetch:spec-types + */ /* 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'; + +/** + * 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 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?: TaskMetadata; +} +/** + * Common params for any request. + * + * @internal + */ +export interface RequestParams { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * 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?: ProgressToken; + [key: string]: unknown; + }; +} + +/** @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 }; +} + +/** @internal */ +export interface NotificationParams { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** @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 }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * @category Common Types + */ +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. + */ +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; + +// 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. + * + * @internal + */ +export interface URLElicitationRequiredError extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @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 `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 `tasks/cancel` request instead of this notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: 'notifications/cancelled'; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @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. + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: 'initialize'; + params: InitializeRequestParams; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @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. + * + * 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?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @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]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: { + /** + * Whether the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: object; + /** + * Whether the client supports tool use via tools and toolChoice parameters. + */ + tools?: object; + }; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: { form?: object; url?: object }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports tasks/list. + */ + list?: object; + /** + * Whether this client supports tasks/cancel. + */ + cancel?: object; + /** + * 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?: object; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented elicitation/create requests. + */ + create?: object; + }; + }; + }; +} + +/** + * 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]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + 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. + */ + 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 tasks/list. + */ + list?: object; + /** + * Whether this server supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented tools/call requests. + */ + call?: object; + }; + }; + }; +} + +/** + * 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 takes 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 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 { + 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. + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: 'ping'; + params?: RequestParams; +} + +/* Progress notifications */ + +/** + * Parameters for a `notifications/progress` notification. + * + * @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. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: 'notifications/progress'; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common parameters for paginated requests. + * + * @internal + */ +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. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: 'resources/list'; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: 'resources/templates/list'; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Common parameters when working with resources. + * + * @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. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: 'resources/read'; + params: ReadResourceRequestParams; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * 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. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: 'notifications/resources/list_changed'; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: 'resources/subscribe'; + params: SubscribeRequestParams; +} + +/** + * 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: 'resources/unsubscribe'; + params: UnsubscribeRequestParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @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 resources/subscribe request. + * + * @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. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @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; +} + +/** + * @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. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: 'prompts/list'; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Parameters for a `prompts/get` request. + * + * @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. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: 'prompts/get'; + params: GetPromptRequestParams; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * 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[]; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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 `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 `resources/list` requests. + * + * @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. + * + * @category Content + */ +export interface EmbeddedResource { + type: 'resource'; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * 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. + * + * @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. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: 'tools/list'; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @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; +} + +/** + * Parameters for a `tools/call` request. + * + * @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. + * + * @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. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: 'notifications/tools/list_changed'; + params?: NotificationParams; +} + +/** + * Additional properties describing a 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. + * + * @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]: object }; + 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 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]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* 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. + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * A response to a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * 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 response to a tasks/get request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * 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 response to a tasks/result request. + * The structure matches the result type of the original request. + * For example, a tools/call task would return the CallToolResult structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * 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 response to a tasks/cancel request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: 'tasks/list'; +} + +/** + * The response to a tasks/list request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * 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. + * + * @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 notifications/message. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: 'logging/setLevel'; + params: SetLevelRequestParams; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @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. + * + * @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. + * + * @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 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?: object; + /** + * 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?: Tool[]; + /** + * 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?: 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. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: 'sampling/createMessage'; + params: CreateMessageRequestParams; +} + +/** + * The client's response to a sampling/createMessage request from the server. + * 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. + * + * @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; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +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. + * + * @category Content + */ +export interface TextContent { + type: 'text'; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A request from the assistant to call a tool. + * + * @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. + * + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @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 ToolUseContent. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as 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 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. + * + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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. + * + * @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` + */ +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. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: 'completion/complete'; + params: CompleteRequestParams; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + 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 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. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: 'roots/list'; + params?: RequestParams; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @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; + + /** + * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * 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 ListRootsRequest. + * + * @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. + * + * @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. + * + * @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. + * + * @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; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: 'string'; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: 'email' | 'uri' | 'date' | 'date-time'; + default?: string; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: 'number' | 'integer'; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: 'boolean'; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @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. + * + * @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. + * + * @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. + * + * @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 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 client's response to an elicitation request. + * + * @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 decline 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[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @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 + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/packages/shared/src/types/types.ts b/packages/core/src/types/types.ts similarity index 99% rename from packages/shared/src/types/types.ts rename to packages/core/src/types/types.ts index 33350ab38..cc086edd4 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -2457,7 +2457,7 @@ export type ResourceContents = Infer; export type TextResourceContents = Infer; export type BlobResourceContents = Infer; export type Resource = Infer; -// TODO: Overlaps with exported `ResourceTemplate` class from `server`. +// TODO: Overlaps with exported `ResourceTemplate` class from `server`. export type ResourceTemplateType = Infer; export type ListResourcesRequest = Infer; export type ListResourcesResult = Infer; diff --git a/packages/shared/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts similarity index 100% rename from packages/shared/src/util/inMemory.ts rename to packages/core/src/util/inMemory.ts diff --git a/packages/shared/src/util/zod-compat.ts b/packages/core/src/util/zod-compat.ts similarity index 100% rename from packages/shared/src/util/zod-compat.ts rename to packages/core/src/util/zod-compat.ts diff --git a/packages/shared/src/util/zod-json-schema-compat.ts b/packages/core/src/util/zod-json-schema-compat.ts similarity index 100% rename from packages/shared/src/util/zod-json-schema-compat.ts rename to packages/core/src/util/zod-json-schema-compat.ts diff --git a/packages/shared/src/validation/ajv-provider.ts b/packages/core/src/validation/ajv-provider.ts similarity index 100% rename from packages/shared/src/validation/ajv-provider.ts rename to packages/core/src/validation/ajv-provider.ts diff --git a/packages/shared/src/validation/cfworker-provider.ts b/packages/core/src/validation/cfworker-provider.ts similarity index 100% rename from packages/shared/src/validation/cfworker-provider.ts rename to packages/core/src/validation/cfworker-provider.ts diff --git a/packages/shared/src/validation/types.ts b/packages/core/src/validation/types.ts similarity index 100% rename from packages/shared/src/validation/types.ts rename to packages/core/src/validation/types.ts diff --git a/test/inMemory.test.ts b/packages/core/test/inMemory.test.ts similarity index 95% rename from test/inMemory.test.ts rename to packages/core/test/inMemory.test.ts index f42420067..1c73139d9 100644 --- a/test/inMemory.test.ts +++ b/packages/core/test/inMemory.test.ts @@ -1,6 +1,6 @@ -import { InMemoryTransport } from '../src/inMemory.js'; -import { JSONRPCMessage } from '../src/types.js'; -import { AuthInfo } from '../src/server/auth/types.js'; +import { InMemoryTransport } from '../src/util/inMemory.js'; +import { JSONRPCMessage } from '../src/types/types.js'; +import { AuthInfo } from '../src/types/types.js'; describe('InMemoryTransport', () => { let clientTransport: InMemoryTransport; diff --git a/packages/shared/test/shared/auth-utils.test.ts b/packages/core/test/shared/auth-utils.test.ts similarity index 100% rename from packages/shared/test/shared/auth-utils.test.ts rename to packages/core/test/shared/auth-utils.test.ts diff --git a/packages/shared/test/shared/auth.test.ts b/packages/core/test/shared/auth.test.ts similarity index 100% rename from packages/shared/test/shared/auth.test.ts rename to packages/core/test/shared/auth.test.ts diff --git a/packages/shared/test/shared/protocol-transport-handling.test.ts b/packages/core/test/shared/protocol-transport-handling.test.ts similarity index 100% rename from packages/shared/test/shared/protocol-transport-handling.test.ts rename to packages/core/test/shared/protocol-transport-handling.test.ts diff --git a/packages/shared/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts similarity index 98% rename from packages/shared/test/shared/protocol.test.ts rename to packages/core/test/shared/protocol.test.ts index 3b7125466..d87900723 100644 --- a/packages/shared/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -712,7 +712,7 @@ describe('protocol tests', () => { 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]; + const sentNotification = sendSpy.mock.calls[0]![0]; expect(sentNotification.method).toBe('test/debounced'); expect(sentNotification.params).toBeUndefined(); }); @@ -1337,7 +1337,7 @@ describe('Task-based execution', () => { await listedTasks.waitForLatch(); expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(3); expect(sentMessage.result.tasks).toEqual([ @@ -1400,7 +1400,7 @@ describe('Task-based execution', () => { await listedTasks.waitForLatch(); expect(mockTaskStore.listTasks).toHaveBeenCalledWith('task-2', undefined); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(2); expect(sentMessage.result.tasks).toEqual([ @@ -1444,7 +1444,7 @@ describe('Task-based execution', () => { await listedTasks.waitForLatch(); expect(mockTaskStore.listTasks).toHaveBeenCalledWith(undefined, undefined); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(3); expect(sentMessage.result.tasks).toEqual([]); @@ -1479,7 +1479,7 @@ describe('Task-based execution', () => { await new Promise(resolve => setTimeout(resolve, 10)); expect(mockTaskStore.listTasks).toHaveBeenCalledWith('bad-cursor', undefined); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; expect(sentMessage.jsonrpc).toBe('2.0'); expect(sentMessage.id).toBe(4); expect(sentMessage.error).toBeDefined(); @@ -1497,7 +1497,7 @@ describe('Task-based execution', () => { setTimeout(() => { transport.onmessage?.({ jsonrpc: '2.0', - id: sendSpy.mock.calls[0][0].id, + id: sendSpy.mock.calls[0]![0].id, result: { tasks: [ { @@ -1525,7 +1525,7 @@ describe('Task-based execution', () => { expect.any(Object) ); expect(result.tasks).toHaveLength(1); - expect(result.tasks[0].taskId).toBe('task-1'); + expect(result.tasks[0]?.taskId).toBe('task-1'); }); it('should call listTasks with cursor from client side', async () => { @@ -1537,7 +1537,7 @@ describe('Task-based execution', () => { setTimeout(() => { transport.onmessage?.({ jsonrpc: '2.0', - id: sendSpy.mock.calls[0][0].id, + id: sendSpy.mock.calls[0]![0].id, result: { tasks: [ { @@ -1567,7 +1567,7 @@ describe('Task-based execution', () => { expect.any(Object) ); expect(result.tasks).toHaveLength(1); - expect(result.tasks[0].taskId).toBe('task-11'); + expect(result.tasks[0]?.taskId).toBe('task-11'); expect(result.nextCursor).toBe('task-11'); }); }); @@ -1620,7 +1620,7 @@ describe('Task-based execution', () => { 'Client cancelled task execution.', undefined ); - const sentMessage = sendSpy.mock.calls[0][0] as unknown as JSONRPCResultResponse; + 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(); @@ -1658,7 +1658,7 @@ describe('Task-based execution', () => { taskDeleted.releaseLatch(); expect(mockTaskStore.getTask).toHaveBeenCalledWith('non-existent', undefined); - const sentMessage = sendSpy.mock.calls[0][0] as unknown as JSONRPCErrorResponse; + 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(); @@ -1706,7 +1706,7 @@ describe('Task-based execution', () => { expect(mockTaskStore.getTask).toHaveBeenCalledWith(completedTask.taskId, undefined); expect(mockTaskStore.updateTaskStatus).not.toHaveBeenCalled(); - const sentMessage = sendSpy.mock.calls[0][0] as unknown as JSONRPCErrorResponse; + 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(); @@ -1723,7 +1723,7 @@ describe('Task-based execution', () => { setTimeout(() => { transport.onmessage?.({ jsonrpc: '2.0', - id: sendSpy.mock.calls[0][0].id, + id: sendSpy.mock.calls[0]![0].id, result: { _meta: {}, taskId: 'task-to-delete', @@ -1798,7 +1798,7 @@ describe('Task-based execution', () => { // 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); + expect(lastGetTaskCall?.[0]).toBe(task.taskId); }); }); @@ -1848,7 +1848,7 @@ describe('Task-based execution', () => { ); // Verify _meta is not present or doesn't contain RELATED_TASK_META_KEY - const response = sendSpy.mock.calls[0][0] as { result?: { _meta?: Record } }; + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; expect(response.result?._meta?.[RELATED_TASK_META_KEY]).toBeUndefined(); }); @@ -1885,7 +1885,7 @@ describe('Task-based execution', () => { 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 } }; + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; expect(response.result?._meta).toEqual({}); }); @@ -1924,7 +1924,7 @@ describe('Task-based execution', () => { 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 } }; + const response = sendSpy.mock.calls[0]![0] as { result?: { _meta?: Record } }; expect(response.result?._meta).toEqual({}); }); @@ -2414,7 +2414,7 @@ describe('Progress notification support for tasks', () => { 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 sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; @@ -2523,7 +2523,7 @@ describe('Progress notification support for tasks', () => { // 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 sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; @@ -2633,7 +2633,7 @@ describe('Progress notification support for tasks', () => { onprogress: progressCallback }); - const sentRequest = sendSpy.mock.calls[0][0] as { id: number; params: { _meta: { progressToken: number } } }; + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; @@ -2731,7 +2731,7 @@ describe('Progress notification support for tasks', () => { onprogress: progressCallback }); - const sentRequest = sendSpy.mock.calls[0][0] as { id: number; params: { _meta: { progressToken: number } } }; + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; @@ -2826,7 +2826,7 @@ describe('Progress notification support for tasks', () => { onprogress: progressCallback }); - const sentRequest = sendSpy.mock.calls[0][0] as { id: number; params: { _meta: { progressToken: number } } }; + const sentRequest = sendSpy.mock.calls[0]![0] as { id: number; params: { _meta: { progressToken: number } } }; const messageId = sentRequest.id; const progressToken = sentRequest.params._meta.progressToken; @@ -2899,7 +2899,7 @@ describe('Progress notification support for tasks', () => { onprogress: onProgressMock }); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; expect(sentMessage.params._meta.progressToken).toBeDefined(); }); @@ -2924,7 +2924,7 @@ describe('Progress notification support for tasks', () => { onprogress: onProgressMock }); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; const progressToken = sentMessage.params._meta.progressToken; // Simulate progress notification @@ -2974,7 +2974,7 @@ describe('Progress notification support for tasks', () => { onprogress: onProgressMock }); - const sentMessage = sendSpy.mock.calls[0][0]; + const sentMessage = sendSpy.mock.calls[0]![0]; const progressToken = sentMessage.params._meta.progressToken; // Simulate CreateTaskResult response @@ -3877,7 +3877,7 @@ describe('Message Interception', () => { expect(queue).toBeDefined(); // Clean up the pending request - const requestId = (sendSpy.mock.calls[0][0] as JSONRPCResultResponse).id; + const requestId = (sendSpy.mock.calls[0]![0] as JSONRPCResultResponse).id; transport.onmessage?.({ jsonrpc: '2.0', id: requestId, @@ -4487,7 +4487,7 @@ describe('requestStream() method', () => { // Should yield exactly one result message expect(messages).toHaveLength(1); - expect(messages[0].type).toBe('result'); + expect(messages[0]?.type).toBe('result'); expect(messages[0]).toHaveProperty('result'); }); @@ -4530,10 +4530,10 @@ describe('requestStream() method', () => { // Should yield exactly one error message expect(messages).toHaveLength(1); - expect(messages[0].type).toBe('error'); + expect(messages[0]?.type).toBe('error'); expect(messages[0]).toHaveProperty('error'); - if (messages[0].type === 'error') { - expect(messages[0].error.message).toContain('Test error'); + if (messages[0]?.type === 'error') { + expect(messages[0]?.error?.message).toContain('Test error'); } }); @@ -4568,9 +4568,9 @@ describe('requestStream() method', () => { // 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'); + expect(messages[0]?.type).toBe('error'); + if (messages[0]?.type === 'error') { + expect(messages[0]?.error?.message).toContain('cancelled'); } }); @@ -4610,7 +4610,7 @@ describe('requestStream() method', () => { // Verify error is terminal and last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage); + assertErrorResponse(lastMessage!); expect(lastMessage.error).toBeDefined(); expect(lastMessage.error.message).toContain('Server error'); }); @@ -4647,7 +4647,7 @@ describe('requestStream() method', () => { // Verify error is terminal and last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage); + assertErrorResponse(lastMessage!); expect(lastMessage.error).toBeDefined(); expect(lastMessage.error.code).toBe(ErrorCode.RequestTimeout); } finally { @@ -4683,7 +4683,7 @@ describe('requestStream() method', () => { // Verify error is terminal and last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage); + assertErrorResponse(lastMessage!); expect(lastMessage.error).toBeDefined(); expect(lastMessage.error.message).toContain('cancelled'); }); @@ -4722,7 +4722,7 @@ describe('requestStream() method', () => { // Verify only one message (the error) was yielded expect(messages).toHaveLength(1); - expect(messages[0].type).toBe('error'); + expect(messages[0]?.type).toBe('error'); // Try to send another message (should be ignored) transport.onmessage?.({ @@ -4796,7 +4796,7 @@ describe('requestStream() method', () => { // Verify error is terminal and last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage); + assertErrorResponse(lastMessage!); expect(lastMessage.error).toBeDefined(); }); @@ -4824,7 +4824,7 @@ describe('requestStream() method', () => { // Verify error is terminal and last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - assertErrorResponse(lastMessage); + assertErrorResponse(lastMessage!); expect(lastMessage.error).toBeDefined(); }); @@ -4863,12 +4863,12 @@ describe('requestStream() method', () => { // Verify error is the last message expect(messages.length).toBeGreaterThan(0); const lastMessage = messages[messages.length - 1]; - expect(lastMessage.type).toBe('error'); + 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'); + expect(messages[i]?.type).not.toBe('error'); + expect(messages[i]?.type).not.toBe('result'); } }); }); @@ -5052,7 +5052,7 @@ describe('Error handling for missing resolvers', () => { expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0][0]; + const calledError = resolverMock.mock.calls[0]![0]; expect(calledError.code).toBe(ErrorCode.InternalError); expect(calledError.message).toContain('Task cancelled or completed'); @@ -5112,7 +5112,7 @@ describe('Error handling for missing resolvers', () => { expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0][0]; + const calledError = resolverMock.mock.calls[0]![0]; expect(calledError.code).toBe(ErrorCode.InternalError); expect(calledError.message).toContain('Task cancelled or completed'); @@ -5266,7 +5266,7 @@ describe('Error handling for missing resolvers', () => { // Verify resolver was called with McpError expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - const calledError = resolverMock.mock.calls[0][0]; + const calledError = resolverMock.mock.calls[0]![0]; expect(calledError.code).toBe(ErrorCode.InvalidRequest); expect(calledError.message).toContain('Invalid request parameters'); @@ -5361,7 +5361,7 @@ describe('Error handling for missing resolvers', () => { // Verify resolver was called with McpError including data expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - const calledError = resolverMock.mock.calls[0][0]; + const calledError = resolverMock.mock.calls[0]![0]; expect(calledError.code).toBe(ErrorCode.InvalidParams); expect(calledError.message).toContain('Validation failed'); expect(calledError.data).toEqual({ field: 'userName', reason: 'required' }); @@ -5482,7 +5482,7 @@ describe('Error handling for missing resolvers', () => { expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); // Verify error has correct properties - const error = resolver2.mock.calls[0][0]; + const error = resolver2.mock.calls[0]![0]; expect(error.code).toBe(ErrorCode.InvalidRequest); expect(error.message).toContain('Request failed'); diff --git a/packages/shared/test/shared/stdio.test.ts b/packages/core/test/shared/stdio.test.ts similarity index 100% rename from packages/shared/test/shared/stdio.test.ts rename to packages/core/test/shared/stdio.test.ts diff --git a/packages/shared/test/shared/toolNameValidation.test.ts b/packages/core/test/shared/toolNameValidation.test.ts similarity index 100% rename from packages/shared/test/shared/toolNameValidation.test.ts rename to packages/core/test/shared/toolNameValidation.test.ts diff --git a/packages/shared/test/shared/uriTemplate.test.ts b/packages/core/test/shared/uriTemplate.test.ts similarity index 100% rename from packages/shared/test/shared/uriTemplate.test.ts rename to packages/core/test/shared/uriTemplate.test.ts diff --git a/test/spec.types.test.ts b/packages/core/test/spec.types.test.ts similarity index 98% rename from test/spec.types.test.ts rename to packages/core/test/spec.types.test.ts index 1fff0f0ff..731db66b8 100644 --- a/test/spec.types.test.ts +++ b/packages/core/test/spec.types.test.ts @@ -5,9 +5,10 @@ * - 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 * as SDKTypes from '../src/types.js'; -import * as SpecTypes from '../src/spec.types.js'; +import * as SDKTypes from '../src/types/types.js'; +import * as SpecTypes from '../src/types/spec.types.js'; import fs from 'node:fs'; +import path from 'node:path'; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ @@ -417,7 +418,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ResourceTemplate: (sdk: SDKTypes.ResourceTemplate, spec: SpecTypes.ResourceTemplate) => { + ResourceTemplate: (sdk: SDKTypes.ResourceTemplateType, spec: SpecTypes.ResourceTemplate) => { sdk = spec; spec = sdk; }, @@ -690,8 +691,8 @@ const sdkTypeChecks = { }; // This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) -const SPEC_TYPES_FILE = 'src/spec.types.ts'; -const SDK_TYPES_FILE = 'src/types.ts'; +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: @@ -700,7 +701,8 @@ const MISSING_SDK_TYPES = [ ]; function extractExportedTypes(source: string): string[] { - return [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)].map(m => m[1]); + const matches = [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)]; + return matches.map(m => m[1]!); } describe('Spec Types', () => { diff --git a/test/types.capabilities.test.ts b/packages/core/test/types.capabilities.test.ts similarity index 99% rename from test/types.capabilities.test.ts rename to packages/core/test/types.capabilities.test.ts index 6d7c39dc7..ed414d2db 100644 --- a/test/types.capabilities.test.ts +++ b/packages/core/test/types.capabilities.test.ts @@ -1,4 +1,4 @@ -import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from '../src/types.js'; +import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from '../src/types/types.js'; describe('ClientCapabilitiesSchema backwards compatibility', () => { describe('ElicitationCapabilitySchema preprocessing', () => { diff --git a/test/types.test.ts b/packages/core/test/types.test.ts similarity index 98% rename from test/types.test.ts rename to packages/core/test/types.test.ts index 78e5bf5a7..d843a247a 100644 --- a/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -15,7 +15,7 @@ import { CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ClientCapabilitiesSchema -} from '../src/types.js'; +} from '../src/types/types.js'; describe('Types', () => { test('should have correct latest protocol version', () => { @@ -273,9 +273,9 @@ describe('Types', () => { 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'); + 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'); } }); @@ -899,8 +899,8 @@ describe('Types', () => { 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'); + expect(content[0]?.type).toBe('text'); + expect(content[1]?.type).toBe('tool_use'); } } diff --git a/test/validation/validation.test.ts b/packages/core/test/validation/validation.test.ts similarity index 100% rename from test/validation/validation.test.ts rename to packages/core/test/validation/validation.test.ts diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 000000000..d6981f997 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./", "../integration/test/helpers"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/sdk-core": ["./src/index.ts"], + "@modelcontextprotocol/sdk-core/*": ["./src/*"], + "@modelcontextprotocol/sdk-client": ["../client/src/index.ts"], + "@modelcontextprotocol/sdk-client/*": ["../client/src/*"], + "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], + "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], + "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/packages/client/vitest.config.ts b/packages/core/vitest.config.js similarity index 100% rename from packages/client/vitest.config.ts rename to packages/core/vitest.config.js diff --git a/packages/examples/eslint.config.mjs b/packages/examples/eslint.config.mjs index 70e926598..83b79879f 100644 --- a/packages/examples/eslint.config.mjs +++ b/packages/examples/eslint.config.mjs @@ -2,6 +2,13 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; -export default baseConfig; - - +export default [ + ...baseConfig, + { + files: ['src/**/*.{ts,tsx,js,jsx,mts,cts}'], + rules: { + // Allow console statements in examples only + 'no-console': 'off' + } + } +]; diff --git a/packages/examples/package.json b/packages/examples/package.json index 4fcd3403e..e6bcffe48 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -26,8 +26,8 @@ "build:esm:w": "npm run build:esm -- -w", "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", "prepack": "npm run build:esm && npm run build:cjs", - "lint": "eslint src/ && prettier --check .", - "lint:fix": "eslint src/ --fix && prettier --write .", + "lint": "eslint test/ && prettier --check .", + "lint:fix": "eslint test/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/examples/src/client/elicitationUrlExample.ts b/packages/examples/src/client/elicitationUrlExample.ts index 3af2d8d33..563a435e4 100644 --- a/packages/examples/src/client/elicitationUrlExample.ts +++ b/packages/examples/src/client/elicitationUrlExample.ts @@ -171,7 +171,7 @@ async function commandLoop(): Promise { if (args.length < 2) { console.log('Usage: call-tool [args]'); } else { - const toolName = args[1]; + const toolName = args[1]!; let toolArgs = {}; if (args.length > 2) { try { diff --git a/packages/examples/src/client/simpleStreamableHttp.ts b/packages/examples/src/client/simpleStreamableHttp.ts index 1c4df44da..ee8eccc05 100644 --- a/packages/examples/src/client/simpleStreamableHttp.ts +++ b/packages/examples/src/client/simpleStreamableHttp.ts @@ -106,7 +106,7 @@ function commandLoop(): void { if (args.length < 2) { console.log('Usage: call-tool [args]'); } else { - const toolName = args[1]; + const toolName = args[1]!; let toolArgs = {}; if (args.length > 2) { try { @@ -149,7 +149,7 @@ function commandLoop(): void { if (args.length < 2) { console.log('Usage: call-tool-task [args]'); } else { - const toolName = args[1]; + const toolName = args[1]!; let toolArgs = {}; if (args.length > 2) { try { @@ -170,7 +170,7 @@ function commandLoop(): void { if (args.length < 2) { console.log('Usage: get-prompt [args]'); } else { - const promptName = args[1]; + const promptName = args[1]!; let promptArgs = {}; if (args.length > 2) { try { @@ -191,7 +191,7 @@ function commandLoop(): void { if (args.length < 2) { console.log('Usage: read-resource '); } else { - await readResource(args[1]); + await readResource(args[1]!); } break; diff --git a/packages/examples/src/client/simpleTaskInteractiveClient.ts b/packages/examples/src/client/simpleTaskInteractiveClient.ts index 679fd69b8..56a36f178 100644 --- a/packages/examples/src/client/simpleTaskInteractiveClient.ts +++ b/packages/examples/src/client/simpleTaskInteractiveClient.ts @@ -59,7 +59,7 @@ 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 firstMessage = params.messages[0]!; const content = firstMessage.content; if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) { prompt = content.text; @@ -192,7 +192,7 @@ 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]; + url = args[i + 1]!; i++; } } diff --git a/packages/examples/src/server/elicitationFormExample.ts b/packages/examples/src/server/elicitationFormExample.ts index ee553ac49..fc387a305 100644 --- a/packages/examples/src/server/elicitationFormExample.ts +++ b/packages/examples/src/server/elicitationFormExample.ts @@ -454,7 +454,7 @@ async function main() { for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); diff --git a/packages/examples/src/server/elicitationUrlExample.ts b/packages/examples/src/server/elicitationUrlExample.ts index f68da60f0..33153fbb9 100644 --- a/packages/examples/src/server/elicitationUrlExample.ts +++ b/packages/examples/src/server/elicitationUrlExample.ts @@ -15,7 +15,13 @@ import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk-server'; import { requireBearerAuth } from '@modelcontextprotocol/sdk-server'; -import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; +import { + CallToolResult, + UrlElicitationRequiredError, + ElicitRequestURLParams, + ElicitResult, + isInitializeRequest +} from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; @@ -759,7 +765,7 @@ process.on('SIGINT', async () => { for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; delete sessionsNeedingElicitation[sessionId]; } catch (error) { diff --git a/packages/examples/src/server/simpleSseServer.ts b/packages/examples/src/server/simpleSseServer.ts index 90e3ef1d3..d0d24609c 100644 --- a/packages/examples/src/server/simpleSseServer.ts +++ b/packages/examples/src/server/simpleSseServer.ts @@ -163,7 +163,7 @@ process.on('SIGINT', async () => { for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); diff --git a/packages/examples/src/server/simpleStreamableHttp.ts b/packages/examples/src/server/simpleStreamableHttp.ts index b06edac05..469d260e4 100644 --- a/packages/examples/src/server/simpleStreamableHttp.ts +++ b/packages/examples/src/server/simpleStreamableHttp.ts @@ -740,7 +740,7 @@ process.on('SIGINT', async () => { for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); diff --git a/packages/examples/src/server/simpleTaskInteractive.ts b/packages/examples/src/server/simpleTaskInteractive.ts index 7dad04c30..ac660aa1a 100644 --- a/packages/examples/src/server/simpleTaskInteractive.ts +++ b/packages/examples/src/server/simpleTaskInteractive.ts @@ -180,12 +180,12 @@ class TaskMessageQueueWithResolvers implements TaskMessageQueue { class TaskStoreWithNotifications extends InMemoryTaskStore { private updateResolvers = new Map void)[]>(); - async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { + override async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { await super.updateTaskStatus(taskId, status, statusMessage, sessionId); this.notifyUpdate(taskId); } - async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { + override async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise { await super.storeTaskResult(taskId, status, result, sessionId); this.notifyUpdate(taskId); } @@ -732,7 +732,7 @@ process.on('SIGINT', async () => { console.log('\nShutting down server...'); for (const sessionId of Object.keys(transports)) { try { - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing session ${sessionId}:`, error); diff --git a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts index 01e7853fe..43fc96fb7 100644 --- a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts +++ b/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts @@ -240,7 +240,7 @@ process.on('SIGINT', async () => { for (const sessionId in transports) { try { console.log(`Closing transport for session ${sessionId}`); - await transports[sessionId].close(); + await transports[sessionId]!.close(); delete transports[sessionId]; } catch (error) { console.error(`Error closing transport for session ${sessionId}:`, error); diff --git a/packages/examples/src/shared/inMemoryEventStore.ts b/packages/examples/src/shared/inMemoryEventStore.ts index d4d02eb91..57c595afc 100644 --- a/packages/examples/src/shared/inMemoryEventStore.ts +++ b/packages/examples/src/shared/inMemoryEventStore.ts @@ -1,5 +1,5 @@ -import { JSONRPCMessage } from '../../types.js'; -import { EventStore } from '../../server/streamableHttp.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import type { EventStore } from '@modelcontextprotocol/sdk-server'; /** * Simple in-memory implementation of the EventStore interface for resumability @@ -21,7 +21,7 @@ export class InMemoryEventStore implements EventStore { */ private getStreamIdFromEventId(eventId: string): string { const parts = eventId.split('_'); - return parts.length > 0 ? parts[0] : ''; + return parts.length > 0 ? parts[0]! : ''; } /** diff --git a/test/examples/server/demoInMemoryOAuthProvider.test.ts b/packages/examples/test/server/demoInMemoryOAuthProvider.test.ts similarity index 96% rename from test/examples/server/demoInMemoryOAuthProvider.test.ts rename to packages/examples/test/server/demoInMemoryOAuthProvider.test.ts index a49a8b426..b67690873 100644 --- a/test/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/packages/examples/test/server/demoInMemoryOAuthProvider.test.ts @@ -1,10 +1,10 @@ import { Response } from 'express'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../../src/examples/server/demoInMemoryOAuthProvider.js'; -import { AuthorizationParams } from '../../../src/server/auth/provider.js'; -import { OAuthClientInformationFull } from '../../../src/shared/auth.js'; -import { InvalidRequestError } from '../../../src/server/auth/errors.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../src/server/demoInMemoryOAuthProvider.js'; +import type { AuthorizationParams } from '../../../server/src/server/auth/provider.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; +import { InvalidRequestError } from '@modelcontextprotocol/sdk-core'; -import { createExpressResponseMock } from '../../helpers/http.js'; +import { createExpressResponseMock } from '../../../integration/test/helpers/http.js'; describe('DemoInMemoryAuthProvider', () => { let provider: DemoInMemoryAuthProvider; diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json index 26c951611..7b67d98fc 100644 --- a/packages/examples/tsconfig.json +++ b/packages/examples/tsconfig.json @@ -3,13 +3,16 @@ "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { - "baseUrl": ".", "paths": { - "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], - "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/shared/src/index.ts"], - "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], - "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"] + "*": ["./*"], + "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], + "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], + "@modelcontextprotocol/sdk-client": ["../client/src/index.ts"], + "@modelcontextprotocol/sdk-client/*": ["../client/src/*"], + "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], + "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], + "@modelcontextprotocol/vitest-config": ["../common/vitest-config/tsconfig.json"], + "@modelcontextprotocol/eslint-config": ["../common/eslint-config/tsconfig.json"] } } } diff --git a/packages/examples/vitest.config.js b/packages/examples/vitest.config.js new file mode 100644 index 000000000..42fc5dab4 --- /dev/null +++ b/packages/examples/vitest.config.js @@ -0,0 +1,18 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; +import { mergeConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default mergeConfig(baseConfig, { + resolve: { + alias: { + // Use workspace source files instead of built dist/ for tests + '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), + '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), + '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') + } + } +}); diff --git a/packages/integration/eslint.config.mjs b/packages/integration/eslint.config.mjs new file mode 100644 index 000000000..951c9f3a9 --- /dev/null +++ b/packages/integration/eslint.config.mjs @@ -0,0 +1,5 @@ +// @ts-check + +import baseConfig from '@modelcontextprotocol/eslint-config'; + +export default baseConfig; diff --git a/packages/integration/package.json b/packages/integration/package.json new file mode 100644 index 000000000..da4e9d4fe --- /dev/null +++ b/packages/integration/package.json @@ -0,0 +1,65 @@ +{ + "name": "@modelcontextprotocol/integration-tests", + "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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./types": { + "import": "./dist/exports/types/index.js" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/*" + ] + } + }, + "files": [ + "dist" + ], + "scripts": { + "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", + "typecheck": "tsgo --noEmit", + "build": "npm run build:esm", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm:w": "npm run build:esm -- -w", + "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint test/ && prettier --check .", + "lint:fix": "eslint test/ --fix && prettier --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" + }, + "devDependencies": { + "@modelcontextprotocol/sdk-core": "workspace:^", + "@modelcontextprotocol/sdk-client": "workspace:^", + "@modelcontextprotocol/sdk-server": "workspace:^", + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^" + } +} diff --git a/src/__fixtures__/serverThatHangs.ts b/packages/integration/test/__fixtures__/serverThatHangs.ts similarity index 88% rename from src/__fixtures__/serverThatHangs.ts rename to packages/integration/test/__fixtures__/serverThatHangs.ts index 82c244aa2..8196d9c83 100644 --- a/src/__fixtures__/serverThatHangs.ts +++ b/packages/integration/test/__fixtures__/serverThatHangs.ts @@ -1,7 +1,7 @@ import { setInterval } from 'node:timers'; import process from 'node:process'; -import { McpServer } from '../server/mcp.js'; -import { StdioServerTransport } from '../server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; const transport = new StdioServerTransport(); diff --git a/src/__fixtures__/testServer.ts b/packages/integration/test/__fixtures__/testServer.ts similarity index 68% rename from src/__fixtures__/testServer.ts rename to packages/integration/test/__fixtures__/testServer.ts index 6401d0f83..5c633ecf5 100644 --- a/src/__fixtures__/testServer.ts +++ b/packages/integration/test/__fixtures__/testServer.ts @@ -1,5 +1,5 @@ -import { McpServer } from '../server/mcp.js'; -import { StdioServerTransport } from '../server/stdio.js'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; const transport = new StdioServerTransport(); diff --git a/src/__fixtures__/zodTestMatrix.ts b/packages/integration/test/__fixtures__/zodTestMatrix.ts similarity index 100% rename from src/__fixtures__/zodTestMatrix.ts rename to packages/integration/test/__fixtures__/zodTestMatrix.ts diff --git a/packages/client/test/client/index.test.ts b/packages/integration/test/client/client.test.ts similarity index 97% rename from packages/client/test/client/index.test.ts rename to packages/integration/test/client/client.test.ts index 9735eb2ba..08bbc921c 100644 --- a/packages/client/test/client/index.test.ts +++ b/packages/integration/test/client/client.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { Client, getSupportedElicitationModes } from '../../src/client/index.js'; +import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/sdk-client'; import { RequestSchema, NotificationSchema, @@ -25,12 +25,12 @@ import { Tool, Prompt, Resource -} from '../../src/types.js'; -import { Transport } from '../../src/shared/transport.js'; -import { Server } from '../../src/server/index.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js'; +} from '@modelcontextprotocol/sdk-core'; +import { Transport } from '@modelcontextprotocol/sdk-core'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; @@ -1292,9 +1292,9 @@ test('should handle tool list changed notification with auto refresh', async () // 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'); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-tool'); }); /*** @@ -1352,8 +1352,8 @@ test('should handle tool list changed notification with manual refresh', async ( // 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(); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toBeNull(); }); /*** @@ -1412,9 +1412,9 @@ test('should handle prompt list changed notification with auto refresh', async ( // 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'); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-prompt'); }); /*** @@ -1467,9 +1467,9 @@ test('should handle resource list changed notification with auto refresh', async // 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'); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(2); + expect(notifications[0]![1]?.[1]!.name).toBe('test-resource'); }); /*** @@ -1553,10 +1553,10 @@ test('should handle multiple list changed handlers configured together', async ( // Both handlers should have received their respective notifications expect(toolNotifications).toHaveLength(1); - expect(toolNotifications[0][1]).toHaveLength(2); + expect(toolNotifications[0]![1]).toHaveLength(2); expect(promptNotifications).toHaveLength(1); - expect(promptNotifications[0][1]).toHaveLength(2); + expect(promptNotifications[0]![1]).toHaveLength(2); }); /*** @@ -1655,8 +1655,8 @@ test('should activate listChanged handler when server advertises capability', as // Handler SHOULD have been called expect(notifications).toHaveLength(1); - expect(notifications[0][0]).toBeNull(); - expect(notifications[0][1]).toHaveLength(1); + expect(notifications[0]![0]).toBeNull(); + expect(notifications[0]![1]).toHaveLength(1); }); /*** @@ -2419,7 +2419,7 @@ describe('Task-based execution', () => { // 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]; + const task = taskList.tasks[0]!; expect(task.status).toBe('completed'); }); @@ -2493,7 +2493,7 @@ describe('Task-based execution', () => { // 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]; + const task = taskList.tasks[0]!; expect(task).toBeDefined(); expect(task.taskId).toBeDefined(); expect(task.status).toBe('completed'); @@ -3492,9 +3492,9 @@ test('should expose requestStream() method for streaming responses', async () => // 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' }]); + 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(); @@ -3547,9 +3547,9 @@ test('should expose callToolStream() method for streaming tool calls', async () // 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' }]); + 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(); @@ -3628,9 +3628,9 @@ test('should validate structured output in callToolStream()', async () => { // 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 }); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + expect(messages[0]!.result.structuredContent).toEqual({ value: 42 }); } await client.close(); @@ -3704,9 +3704,9 @@ test('callToolStream() should yield error when structuredContent does not match } 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/); + 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(); @@ -3776,9 +3776,9 @@ test('callToolStream() should yield error when tool with outputSchema returns no } 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/); + 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(); @@ -3841,9 +3841,9 @@ test('callToolStream() should handle tools without outputSchema normally', async } 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' }]); + 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(); @@ -3936,10 +3936,10 @@ test('callToolStream() should handle complex JSON schema validation', async () = } 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(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); } @@ -4015,9 +4015,9 @@ test('callToolStream() should yield error with additional properties when not al } 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/); + 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(); @@ -4090,10 +4090,10 @@ test('callToolStream() should not validate structuredContent when isError is tru // 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' }]); + 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(); diff --git a/packages/client/test/experimental/tasks/task-listing.test.ts b/packages/integration/test/experimental/tasks/task-listing.test.ts similarity index 96% rename from packages/client/test/experimental/tasks/task-listing.test.ts rename to packages/integration/test/experimental/tasks/task-listing.test.ts index bf51f1404..49518459d 100644 --- a/packages/client/test/experimental/tasks/task-listing.test.ts +++ b/packages/integration/test/experimental/tasks/task-listing.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { ErrorCode, McpError } from '../../../src/types.js'; +import { ErrorCode, McpError } from '@modelcontextprotocol/sdk-core'; import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js'; describe('Task Listing with Pagination', () => { @@ -73,7 +73,7 @@ describe('Task Listing with Pagination', () => { // Get all tasks to get a valid cursor const allTasks = taskStore.getAllTasks(); - const validCursor = allTasks[2].taskId; + 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); @@ -109,7 +109,7 @@ describe('Task Listing with Pagination', () => { // 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); + expect(listResult.tasks[0]!.taskId).toBe(task.taskId); }); it('should not include related-task metadata in list response', async () => { diff --git a/test/experimental/tasks/task.test.ts b/packages/integration/test/experimental/tasks/task.test.ts similarity index 96% rename from test/experimental/tasks/task.test.ts rename to packages/integration/test/experimental/tasks/task.test.ts index 37e3938d2..939adcd38 100644 --- a/test/experimental/tasks/task.test.ts +++ b/packages/integration/test/experimental/tasks/task.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { isTerminal } from '../../../src/experimental/tasks/interfaces.js'; -import type { Task } from '../../../src/types.js'; +import { isTerminal } from '@modelcontextprotocol/sdk-core'; +import type { Task } from '@modelcontextprotocol/sdk-server'; describe('Task utility functions', () => { describe('isTerminal', () => { diff --git a/packages/integration/test/helpers/http.ts b/packages/integration/test/helpers/http.ts new file mode 100644 index 000000000..291cc37fa --- /dev/null +++ b/packages/integration/test/helpers/http.ts @@ -0,0 +1,96 @@ +import type http from 'node:http'; +import { type Server } from 'node:http'; +import type { Response } from 'express'; +import { AddressInfo } from 'node:net'; +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(): http.ServerResponse { + const res = { + writeHead: vi.fn().mockReturnThis(), + write: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis() + }; + + return res as unknown as http.ServerResponse; +} diff --git a/test/helpers/mcp.ts b/packages/integration/test/helpers/mcp.ts similarity index 81% rename from test/helpers/mcp.ts rename to packages/integration/test/helpers/mcp.ts index 6cd08fdf0..f69d9ee92 100644 --- a/test/helpers/mcp.ts +++ b/packages/integration/test/helpers/mcp.ts @@ -1,8 +1,8 @@ -import { InMemoryTransport } from '../../src/inMemory.js'; -import { Client } from '../../src/client/index.js'; -import { Server } from '../../src/server/index.js'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js'; -import type { ClientCapabilities, ServerCapabilities } from '../../src/types.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/sdk-server'; export interface InMemoryTaskEnvironment { client: Client; diff --git a/packages/integration/test/helpers/oauth.ts b/packages/integration/test/helpers/oauth.ts new file mode 100644 index 000000000..49d141c7c --- /dev/null +++ b/packages/integration/test/helpers/oauth.ts @@ -0,0 +1,88 @@ +import type { FetchLike } from '../../../core/src/shared/transport.js'; + +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 new Response( + JSON.stringify({ + 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 new Response( + JSON.stringify({ + 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 new Response( + JSON.stringify({ + 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/tasks.ts b/packages/integration/test/helpers/tasks.ts similarity index 93% rename from test/helpers/tasks.ts rename to packages/integration/test/helpers/tasks.ts index d2fed9f5d..60aa4cd7c 100644 --- a/test/helpers/tasks.ts +++ b/packages/integration/test/helpers/tasks.ts @@ -1,4 +1,4 @@ -import type { Task } from '../../src/types.js'; +import type { Task } from '../../../core/src/types/types.js'; /** * Polls the provided getTask function until the task reaches the desired status or times out. diff --git a/test/integration-tests/processCleanup.test.ts b/packages/integration/test/processCleanup.test.ts similarity index 87% rename from test/integration-tests/processCleanup.test.ts rename to packages/integration/test/processCleanup.test.ts index 11940697b..fe4adf149 100644 --- a/test/integration-tests/processCleanup.test.ts +++ b/packages/integration/test/processCleanup.test.ts @@ -1,12 +1,13 @@ import path from 'node:path'; import { Readable, Writable } from 'node:stream'; -import { Client } from '../../src/client/index.js'; -import { StdioClientTransport } from '../../src/client/stdio.js'; -import { Server } from '../../src/server/index.js'; -import { StdioServerTransport } from '../../src/server/stdio.js'; -import { LoggingMessageNotificationSchema } from '../../src/types.js'; - -const FIXTURES_DIR = path.resolve(__dirname, '../../src/__fixtures__'); +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk-client'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; +import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; + +// Use the local fixtures directory alongside this test file +const FIXTURES_DIR = path.resolve(__dirname, './__fixtures__'); describe('Process cleanup', () => { vi.setConfig({ testTimeout: 5000 }); // 5 second timeout diff --git a/test/server/index.test.ts b/packages/integration/test/server.test.ts similarity index 99% rename from test/server/index.test.ts rename to packages/integration/test/server.test.ts index e434e57fc..543c82739 100644 --- a/test/server/index.test.ts +++ b/packages/integration/test/server.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import supertest from 'supertest'; -import { Client } from '../../src/client/index.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import type { Transport } from '../../src/shared/transport.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import type { Transport } from '@modelcontextprotocol/sdk-core'; import { CreateMessageRequestSchema, CreateMessageResultSchema, @@ -22,16 +22,16 @@ import { SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, CreateTaskResultSchema -} from '../../src/types.js'; -import { Server } from '../../src/server/index.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js'; -import { CallToolRequestSchema, CallToolResultSchema } from '../../src/types.js'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../../src/validation/types.js'; -import type { AnyObjectSchema } from '../../src/server/zod-compat.js'; +} from '@modelcontextprotocol/sdk-core'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import { CallToolRequestSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk-core'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; +import type { AnyObjectSchema } from '@modelcontextprotocol/sdk-core'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; -import { createMcpExpressApp } from '../../src/server/express.js'; +import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; describe('Zod v3', () => { /* @@ -3021,11 +3021,11 @@ describe('Task-based execution', () => { // Verify all tasks completed successfully for (let i = 0; i < taskIds.length; i++) { - const task = await client.experimental.tasks.getTask(taskIds[i]); + const task = await client.experimental.tasks.getTask(taskIds[i]!); expect(task.status).toBe('completed'); - expect(task.taskId).toBe(taskIds[i]); + expect(task.taskId).toBe(taskIds[i]!); - const result = await client.experimental.tasks.getTaskResult(taskIds[i], CallToolResultSchema); + const result = await client.experimental.tasks.getTaskResult(taskIds[i]!, CallToolResultSchema); expect(result.content).toEqual([{ type: 'text', text: `Completed task ${i + 1}` }]); } diff --git a/test/server/elicitation.test.ts b/packages/integration/test/server/elicitation.test.ts similarity index 98% rename from test/server/elicitation.test.ts rename to packages/integration/test/server/elicitation.test.ts index c6f297b46..ea783ebe2 100644 --- a/test/server/elicitation.test.ts +++ b/packages/integration/test/server/elicitation.test.ts @@ -7,12 +7,12 @@ * Per the MCP spec, elicitation only supports object schemas, not primitives. */ -import { Client } from '../../src/client/index.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import { ElicitRequestFormParams, ElicitRequestSchema } from '../../src/types.js'; -import { AjvJsonSchemaValidator } from '../../src/validation/ajv-provider.js'; -import { CfWorkerJsonSchemaValidator } from '../../src/validation/cfworker-provider.js'; -import { Server } from '../../src/server/index.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import { ElicitRequestFormParams, ElicitRequestSchema } from '@modelcontextprotocol/sdk-core'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; +import { Server } from '@modelcontextprotocol/sdk-server'; const ajvProvider = new AjvJsonSchemaValidator(); const cfWorkerProvider = new CfWorkerJsonSchemaValidator(); diff --git a/test/server/mcp.test.ts b/packages/integration/test/server/mcp.test.ts similarity index 97% rename from test/server/mcp.test.ts rename to packages/integration/test/server/mcp.test.ts index f6c2124e1..70696f188 100644 --- a/test/server/mcp.test.ts +++ b/packages/integration/test/server/mcp.test.ts @@ -1,7 +1,7 @@ -import { Client } from '../../src/client/index.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import { getDisplayName } from '../../src/shared/metadataUtils.js'; -import { UriTemplate } from '../../src/shared/uriTemplate.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import { getDisplayName } from '@modelcontextprotocol/sdk-core'; +import { UriTemplate } from '@modelcontextprotocol/sdk-core'; import { CallToolResultSchema, type CallToolResult, @@ -18,11 +18,11 @@ import { type TextContent, UrlElicitationRequiredError, ErrorCode -} from '../../src/types.js'; -import { completable } from '../../src/server/completable.js'; -import { McpServer, ResourceTemplate } from '../../src/server/mcp.js'; -import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; +} from '@modelcontextprotocol/sdk-core'; +import { completable } from '../../../server/src/server/completable.js'; +import { McpServer, ResourceTemplate } from '../../../server/src/server/mcp.js'; +import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-core'; +import { zodTestMatrix, type ZodMatrixEntry } from '../../../server/test/server/__fixtures__/zodTestMatrix.js'; function createLatch() { let latch = false; @@ -292,8 +292,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toEqual({ + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.inputSchema).toEqual({ type: 'object', properties: {} }); @@ -447,7 +447,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ListToolsResultSchema ); - expect(listResult.tools[0].inputSchema).toMatchObject({ + expect(listResult.tools[0]!.inputSchema).toMatchObject({ properties: { name: { type: 'string' }, value: { type: 'number' } @@ -540,7 +540,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ListToolsResultSchema ); - expect(listResult.tools[0].outputSchema).toMatchObject({ + expect(listResult.tools[0]!.outputSchema).toMatchObject({ type: 'object', properties: { result: { type: 'number' }, @@ -684,16 +684,16 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' }, value: { type: 'number' } } }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.inputSchema).toEqual(result.tools[0]!.inputSchema); }); /*** @@ -747,10 +747,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); 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'); + 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'); }); /*** @@ -802,13 +802,13 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].annotations).toEqual({ + expect(result.tools[0]!.name).toBe('test'); + expect(result.tools[0]!.annotations).toEqual({ title: 'Test Tool', readOnlyHint: true }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].annotations).toEqual({ + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.annotations).toEqual({ title: 'Test Tool', readOnlyHint: true }); @@ -849,18 +849,18 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ + 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({ + expect(result.tools[0]!.annotations).toEqual({ title: 'Test Tool', readOnlyHint: true }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.inputSchema).toEqual(result.tools[0]!.inputSchema); + expect(result.tools[1]!.annotations).toEqual(result.tools[0]!.annotations); }); /*** @@ -909,21 +909,21 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('A tool with everything'); - expect(result.tools[0].inputSchema).toMatchObject({ + 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({ + expect(result.tools[0]!.annotations).toEqual({ title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.description).toBe('A tool with everything'); + expect(result.tools[1]!.inputSchema).toEqual(result.tools[0]!.inputSchema); + expect(result.tools[1]!.annotations).toEqual(result.tools[0]!.annotations); }); /*** @@ -976,21 +976,21 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(2); - 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({ + 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({ + expect(result.tools[0]!.annotations).toEqual({ title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything but empty params'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); + expect(result.tools[1]!.name).toBe('test (new api)'); + expect(result.tools[1]!.description).toBe('A tool with everything but empty params'); + expect(result.tools[1]!.inputSchema).toEqual(result.tools[0]!.inputSchema); + expect(result.tools[1]!.annotations).toEqual(result.tools[0]!.annotations); }); /*** @@ -1199,7 +1199,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toMatchObject({ + expect(listResult.tools[0]!.outputSchema).toMatchObject({ type: 'object', properties: { processedInput: { type: 'string' }, @@ -1823,9 +1823,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); 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); + 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); }); /*** @@ -1859,8 +1859,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-without-meta'); - expect(result.tools[0]._meta).toBeUndefined(); + 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 () => { @@ -1924,8 +1924,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('task-tool'); - expect(result.tools[0].execution).toEqual({ + expect(result.tools[0]!.name).toBe('task-tool'); + expect(result.tools[0]!.execution).toEqual({ taskSupport: 'required' }); @@ -1993,8 +1993,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('optional-task-tool'); - expect(result.tools[0].execution).toEqual({ + expect(result.tools[0]!.name).toBe('optional-task-tool'); + expect(result.tools[0]!.execution).toEqual({ taskSupport: 'optional' }); @@ -2087,8 +2087,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.resources).toHaveLength(1); - expect(result.resources[0].name).toBe('test'); - expect(result.resources[0].uri).toBe('test://resource'); + expect(result.resources[0]!.name).toBe('test'); + expect(result.resources[0]!.uri).toBe('test://resource'); }); /*** @@ -2332,7 +2332,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); expect(result.resources).toHaveLength(1); - expect(result.resources[0].uri).toBe('test://resource2'); + expect(result.resources[0]!.uri).toBe('test://resource2'); }); /*** @@ -2439,9 +2439,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); 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({ + 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 @@ -2482,8 +2482,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.resourceTemplates).toHaveLength(1); - expect(result.resourceTemplates[0].name).toBe('test'); - expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); + expect(result.resourceTemplates[0]!.name).toBe('test'); + expect(result.resourceTemplates[0]!.uriTemplate).toBe('test://resource/{id}'); }); /*** @@ -2537,10 +2537,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); 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'); + 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'); }); /*** @@ -3037,8 +3037,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toBeUndefined(); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.arguments).toBeUndefined(); }); /*** * Test: Updating Existing Prompt @@ -3184,8 +3184,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ListPromptsResultSchema ); - expect(listResult.prompts[0].arguments).toHaveLength(2); - expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); + expect(listResult.prompts[0]!.arguments).toHaveLength(2); + expect(listResult.prompts[0]!.arguments!.map(a => a.name).sort()).toEqual(['name', 'value']); // Call the prompt with the new schema const getResult = await client.request( @@ -3343,7 +3343,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('prompt2'); + expect(result.prompts[0]!.name).toBe('prompt2'); }); /*** @@ -3390,8 +3390,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toEqual([ + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.arguments).toEqual([ { name: 'name', required: true }, { name: 'value', required: true } ]); @@ -3434,8 +3434,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].description).toBe('Test description'); + expect(result.prompts[0]!.name).toBe('test'); + expect(result.prompts[0]!.description).toBe('Test description'); }); /*** @@ -3983,14 +3983,14 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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'); + expect(result.resources[1]!.name).toBe('Resource 2'); + expect(result.resources[1]!.description).toBe('Template description'); + expect(result.resources[1]!.mimeType).toBe('application/json'); }); /*** @@ -4050,9 +4050,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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 () => { @@ -4090,8 +4090,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test-prompt'); - expect(result.prompts[0].arguments).toEqual([ + expect(result.prompts[0]!.name).toBe('test-prompt'); + expect(result.prompts[0]!.arguments).toEqual([ { name: 'name', description: undefined, @@ -5170,8 +5170,8 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { ); expect(result.resources).toHaveLength(1); - expect(result.resources[0].name).toBe('test'); - expect(result.resources[0].uri).toBe('test://resource'); + expect(result.resources[0]!.name).toBe('test'); + expect(result.resources[0]!.uri).toBe('test://resource'); }); /*** @@ -5315,14 +5315,14 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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'); + expect(result.resources[1]!.name).toBe('Resource 2'); + expect(result.resources[1]!.description).toBe('Template description'); + expect(result.resources[1]!.mimeType).toBe('application/json'); }); /*** @@ -5382,9 +5382,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + expect(result.resources[0]!.name).toBe('Overridden Name'); + expect(result.resources[0]!.description).toBe('Overridden description'); + expect(result.resources[0]!.mimeType).toBe('text/markdown'); }); }); @@ -6346,7 +6346,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Should receive error result expect(result.isError).toBe(true); const content = result.content as TextContent[]; - expect(content[0].text).toContain('requires task augmentation'); + expect(content[0]!.text).toContain('requires task augmentation'); taskStore.cleanup(); }); diff --git a/test/integration-tests/stateManagementStreamableHttp.test.ts b/packages/integration/test/stateManagementStreamableHttp.test.ts similarity index 96% rename from test/integration-tests/stateManagementStreamableHttp.test.ts rename to packages/integration/test/stateManagementStreamableHttp.test.ts index d79d95c75..f3d7eb0cf 100644 --- a/test/integration-tests/stateManagementStreamableHttp.test.ts +++ b/packages/integration/test/stateManagementStreamableHttp.test.ts @@ -1,18 +1,18 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '../../src/client/index.js'; -import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, ListPromptsResultSchema, LATEST_PROTOCOL_VERSION -} from '../../src/types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; -import { listenOnRandomPort } from '../helpers/http.js'; +} from '@modelcontextprotocol/sdk-server'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { listenOnRandomPort } from './helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/test/integration-tests/taskLifecycle.test.ts b/packages/integration/test/taskLifecycle.test.ts similarity index 97% rename from test/integration-tests/taskLifecycle.test.ts rename to packages/integration/test/taskLifecycle.test.ts index 629a61b66..cdd09a226 100644 --- a/test/integration-tests/taskLifecycle.test.ts +++ b/packages/integration/test/taskLifecycle.test.ts @@ -1,9 +1,9 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '../../src/client/index.js'; -import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { CallToolResultSchema, CreateTaskResultSchema, @@ -13,12 +13,12 @@ import { McpError, RELATED_TASK_META_KEY, TaskSchema -} from '../../src/types.js'; +} from '@modelcontextprotocol/sdk-server'; import { z } from 'zod'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js'; -import type { TaskRequestOptions } from '../../src/shared/protocol.js'; -import { listenOnRandomPort } from '../helpers/http.js'; -import { waitForTaskStatus } from '../helpers/tasks.js'; +import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import type { TaskRequestOptions } from '@modelcontextprotocol/sdk-server'; +import { listenOnRandomPort } from './helpers/http.js'; +import { waitForTaskStatus } from './helpers/tasks.js'; describe('Task Lifecycle Integration Tests', () => { let server: Server; @@ -556,9 +556,9 @@ describe('Task Lifecycle Integration Tests', () => { // 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'); + 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' }]); @@ -1274,14 +1274,14 @@ describe('Task Lifecycle Integration Tests', () => { // 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'); + 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; + 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); @@ -1470,8 +1470,8 @@ describe('Task Lifecycle Integration Tests', () => { // 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'); + 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' }]); @@ -1638,15 +1638,15 @@ describe('Task Lifecycle Integration Tests', () => { expect(messages.length).toBeGreaterThanOrEqual(2); // First message should be taskCreated - expect(messages[0].type).toBe('taskCreated'); - expect(messages[0].task).toBeDefined(); + 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[messages.length - 1]; + const lastMessage = messages[messages.length - 1]!; expect(lastMessage.type).toBe('result'); expect(lastMessage.result).toBeDefined(); diff --git a/test/integration-tests/taskResumability.test.ts b/packages/integration/test/taskResumability.test.ts similarity index 95% rename from test/integration-tests/taskResumability.test.ts rename to packages/integration/test/taskResumability.test.ts index 187a3d2ff..b9f2bccb6 100644 --- a/test/integration-tests/taskResumability.test.ts +++ b/packages/integration/test/taskResumability.test.ts @@ -1,13 +1,13 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '../../src/client/index.js'; -import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../src/server/streamableHttp.js'; -import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../src/types.js'; -import { InMemoryEventStore } from '../../src/examples/shared/inMemoryEventStore.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; -import { listenOnRandomPort } from '../helpers/http.js'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { McpServer } from '@modelcontextprotocol/sdk-server'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { CallToolResultSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; +import { InMemoryEventStore } from '@modelcontextprotocol/sdk-server'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { listenOnRandomPort } from './helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/test/server/title.test.ts b/packages/integration/test/title.test.ts similarity index 80% rename from test/server/title.test.ts rename to packages/integration/test/title.test.ts index de353af30..de389eed2 100644 --- a/test/server/title.test.ts +++ b/packages/integration/test/title.test.ts @@ -1,8 +1,8 @@ -import { Server } from '../../src/server/index.js'; -import { Client } from '../../src/client/index.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import { McpServer, ResourceTemplate } from '../../src/server/mcp.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; +import { Server } from '@modelcontextprotocol/sdk-server'; +import { Client } from '@modelcontextprotocol/sdk-client'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk-server'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; @@ -33,9 +33,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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 () => { @@ -53,9 +53,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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 () => { @@ -76,9 +76,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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]!.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 () => { @@ -111,10 +111,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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); + 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 () => { @@ -148,10 +148,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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 () => { @@ -184,10 +184,10 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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'); + 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' }); diff --git a/packages/integration/tsconfig.json b/packages/integration/tsconfig.json new file mode 100644 index 000000000..2361b208c --- /dev/null +++ b/packages/integration/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "paths": { + "*": ["./*"], + "@modelcontextprotocol/sdk-core": ["./node_modules/@modelcontextprotocol/sdk-core/src/index.ts"], + "@modelcontextprotocol/sdk-client": ["./node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/sdk-server": ["./node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + } + } +} diff --git a/packages/integration/vitest.config.js b/packages/integration/vitest.config.js new file mode 100644 index 000000000..1a19ed409 --- /dev/null +++ b/packages/integration/vitest.config.js @@ -0,0 +1,18 @@ +import baseConfig from '../../common/vitest-config/vitest.config.js'; +import { mergeConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default mergeConfig(baseConfig, { + resolve: { + alias: { + // Use workspace source files instead of built dist/ for tests + '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), + '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), + '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') + } + } +}); diff --git a/packages/server/eslint.config.mjs b/packages/server/eslint.config.mjs index 70e926598..951c9f3a9 100644 --- a/packages/server/eslint.config.mjs +++ b/packages/server/eslint.config.mjs @@ -3,5 +3,3 @@ import baseConfig from '@modelcontextprotocol/eslint-config'; export default baseConfig; - - diff --git a/packages/server/package.json b/packages/server/package.json index 7a6cf5cd7..7aecc1626 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,7 +51,7 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/shared": "workspace:^", + "@modelcontextprotocol/sdk-core": "workspace:^", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", diff --git a/packages/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts index e945b2374..4eb097403 100644 --- a/packages/server/src/experimental/tasks/index.ts +++ b/packages/server/src/experimental/tasks/index.ts @@ -11,6 +11,3 @@ export * from './interfaces.js'; // Wrapper classes export * from './server.js'; export * from './mcp-server.js'; - -// Store implementations -export * from './stores/in-memory.js'; \ No newline at end of file diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index 1e604db4b..9b4ff7595 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -3,14 +3,11 @@ * WARNING: These APIs are experimental and may change without notice. */ -import { - Result, - CallToolResult, - GetTaskResult} from '@modelcontextprotocol/shared'; -import { CreateTaskResult } from '@modelcontextprotocol/shared'; -import type { CreateTaskRequestHandlerExtra, TaskRequestHandlerExtra } from '@modelcontextprotocol/shared'; -import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; -import { BaseToolCallback } from 'src/server/mcp.js'; +import { Result, CallToolResult, GetTaskResult } from '@modelcontextprotocol/sdk-core'; +import { CreateTaskResult } from '@modelcontextprotocol/sdk-core'; +import type { CreateTaskRequestHandlerExtra, TaskRequestHandlerExtra } from '@modelcontextprotocol/sdk-core'; +import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk-core'; +import { BaseToolCallback } from '../../server/mcp.js'; // ============================================================================ // Task Handler Types (for registerToolTask) diff --git a/packages/server/src/experimental/tasks/mcp-server.ts b/packages/server/src/experimental/tasks/mcp-server.ts index 12d100203..43e9252c7 100644 --- a/packages/server/src/experimental/tasks/mcp-server.ts +++ b/packages/server/src/experimental/tasks/mcp-server.ts @@ -6,10 +6,10 @@ */ import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; -import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/shared'; -import type { ToolAnnotations, ToolExecution } from '@modelcontextprotocol/shared'; +import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk-core'; +import type { ToolAnnotations, ToolExecution } from '@modelcontextprotocol/sdk-core'; import type { ToolTaskHandler } from './interfaces.js'; -import type { TaskToolExecution } from '@modelcontextprotocol/shared'; +import type { TaskToolExecution } from '@modelcontextprotocol/sdk-core'; /** * Internal interface for accessing McpServer's private _createRegisteredTool method. diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 3db65b7c9..7a6f98777 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -6,10 +6,18 @@ */ import type { Server } from '../../server/server.js'; -import type { RequestOptions } from '@modelcontextprotocol/shared'; -import type { ResponseMessage } from '@modelcontextprotocol/shared'; -import type { AnySchema, SchemaOutput } from '@modelcontextprotocol/shared'; -import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/shared'; +import type { RequestOptions } from '../../../../core/src/index.js'; +import type { ResponseMessage } from '../../../../core/src/index.js'; +import type { AnySchema, SchemaOutput } from '../../../../core/src/index.js'; +import type { + ServerRequest, + Notification, + Request, + Result, + GetTaskResult, + ListTasksResult, + CancelTaskResult +} from '../../../../core/src/index.js'; /** * Experimental task features for low-level MCP servers. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7cf838144..13d2af45c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,6 +5,7 @@ export * from './server/server.js'; export * from './server/sse.js'; export * from './server/stdio.js'; export * from './server/streamableHttp.js'; +export * from './server/inMemoryEventStore.js'; // auth exports export * from './server/auth/index.js'; @@ -13,4 +14,4 @@ export * from './server/auth/index.js'; export * from './experimental/index.js'; // re-export shared types -export * from '@modelcontextprotocol/shared'; +export * from '../../core/src/index.js'; diff --git a/packages/server/src/server/auth/clients.ts b/packages/server/src/server/auth/clients.ts index c89f5db99..e4a10e3c6 100644 --- a/packages/server/src/server/auth/clients.ts +++ b/packages/server/src/server/auth/clients.ts @@ -1,4 +1,4 @@ -import { OAuthClientInformationFull } from '@modelcontextprotocol/shared'; +import { OAuthClientInformationFull } from '../../../../core/src/index.js'; /** * Stores information about registered OAuth clients for this server. diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts index 6e9f86b49..1b883e61a 100644 --- a/packages/server/src/server/auth/handlers/authorize.ts +++ b/packages/server/src/server/auth/handlers/authorize.ts @@ -4,7 +4,7 @@ import express from 'express'; import { OAuthServerProvider } from '../provider.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; +import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -128,7 +128,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A { state, scopes: requestedScopes, - redirectUri: redirect_uri, + redirectUri: redirect_uri!, // TODO: Someone to look at. Strict tsconfig showed this could be undefined, while the return type is string. codeChallenge: code_challenge, resource: resource ? new URL(resource) : undefined }, @@ -137,10 +137,10 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A } catch (error) { // Post-redirect errors - redirect with error parameters if (error instanceof OAuthError) { - res.redirect(302, createErrorRedirect(redirect_uri, error, state)); + res.redirect(302, createErrorRedirect(redirect_uri!, error, state)); } else { const serverError = new ServerError('Internal Server Error'); - res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); + res.redirect(302, createErrorRedirect(redirect_uri!, serverError, state)); } } }); diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts index 04cd8c8fd..08370d37a 100644 --- a/packages/server/src/server/auth/handlers/metadata.ts +++ b/packages/server/src/server/auth/handlers/metadata.ts @@ -1,5 +1,5 @@ import express, { RequestHandler } from 'express'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/shared'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../../../core/src/index.js'; import cors from 'cors'; import { allowedMethods } from '../middleware/allowedMethods.js'; diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts index 1be1ec63d..0fb69512e 100644 --- a/packages/server/src/server/auth/handlers/register.ts +++ b/packages/server/src/server/auth/handlers/register.ts @@ -1,11 +1,11 @@ import express, { RequestHandler } from 'express'; -import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '@modelcontextprotocol/shared'; +import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../../../core/src/index.js'; import crypto from 'node:crypto'; import cors from 'cors'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; +import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; export type ClientRegistrationHandlerOptions = { /** diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts index 7805cd394..ada7e4ac7 100644 --- a/packages/server/src/server/auth/handlers/revoke.ts +++ b/packages/server/src/server/auth/handlers/revoke.ts @@ -2,10 +2,10 @@ import { OAuthServerProvider } from '../provider.js'; import express, { RequestHandler } from 'express'; import cors from 'cors'; import { authenticateClient } from '../middleware/clientAuth.js'; -import { OAuthTokenRevocationRequestSchema } from '@modelcontextprotocol/shared'; +import { OAuthTokenRevocationRequestSchema } from '../../../../../core/src/index.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/shared'; +import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; export type RevocationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts index b42da6cac..1ec27b3a8 100644 --- a/packages/server/src/server/auth/handlers/token.ts +++ b/packages/server/src/server/auth/handlers/token.ts @@ -13,7 +13,7 @@ import { ServerError, TooManyRequestsError, OAuthError -} from '@modelcontextprotocol/shared'; +} from '../../../../../core/src/index.js'; export type TokenHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/index.ts b/packages/server/src/server/auth/index.ts index 2c5ea4aab..10c9d7ace 100644 --- a/packages/server/src/server/auth/index.ts +++ b/packages/server/src/server/auth/index.ts @@ -12,4 +12,4 @@ export * from './middleware/allowedMethods.js'; export * from './middleware/bearerAuth.js'; export * from './middleware/clientAuth.js'; -export * from './providers/proxyProvider.js'; \ No newline at end of file +export * from './providers/proxyProvider.js'; diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts index 45e38e3c0..0f9d6be85 100644 --- a/packages/server/src/server/auth/middleware/allowedMethods.ts +++ b/packages/server/src/server/auth/middleware/allowedMethods.ts @@ -1,5 +1,5 @@ import { RequestHandler } from 'express'; -import { MethodNotAllowedError } from '@modelcontextprotocol/shared'; +import { MethodNotAllowedError } from '../../../../../core/src/index.js'; /** * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts index d00ee1c5c..015352623 100644 --- a/packages/server/src/server/auth/middleware/bearerAuth.ts +++ b/packages/server/src/server/auth/middleware/bearerAuth.ts @@ -1,7 +1,7 @@ import { RequestHandler } from 'express'; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/shared'; +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../../../../../core/src/index.js'; import { OAuthTokenVerifier } from '../provider.js'; -import { AuthInfo } from '@modelcontextprotocol/shared'; +import { AuthInfo } from '../../../../../core/src/index.js'; export type BearerAuthMiddlewareOptions = { /** @@ -46,7 +46,7 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad } const [type, token] = authHeader.split(' '); - if (type.toLowerCase() !== 'bearer' || !token) { + if (type!.toLowerCase() !== 'bearer' || !token) { throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts index c1696a0e7..83e9bd593 100644 --- a/packages/server/src/server/auth/middleware/clientAuth.ts +++ b/packages/server/src/server/auth/middleware/clientAuth.ts @@ -1,8 +1,8 @@ import * as z from 'zod/v4'; import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; -import { OAuthClientInformationFull } from '@modelcontextprotocol/shared'; -import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '@modelcontextprotocol/shared'; +import { OAuthClientInformationFull } from '../../../../../core/src/index.js'; +import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../../../../../core/src/index.js'; export type ClientAuthenticationMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts index 6f354dcd1..f6ea59c50 100644 --- a/packages/server/src/server/auth/provider.ts +++ b/packages/server/src/server/auth/provider.ts @@ -1,7 +1,7 @@ import { Response } from 'express'; import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/shared'; -import { AuthInfo } from '@modelcontextprotocol/shared'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../core/src/index.js'; +import { AuthInfo } from '../../../../core/src/index.js'; export type AuthorizationParams = { state?: string; diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts index 7f2258a72..4ab90410f 100644 --- a/packages/server/src/server/auth/providers/proxyProvider.ts +++ b/packages/server/src/server/auth/providers/proxyProvider.ts @@ -6,11 +6,11 @@ import { OAuthTokenRevocationRequest, OAuthTokens, OAuthTokensSchema -} from '@modelcontextprotocol/shared'; -import { AuthInfo } from '@modelcontextprotocol/shared'; +} from '../../../../../core/src/index.js'; +import { AuthInfo } from '../../../../../core/src/index.js'; import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; -import { ServerError } from '@modelcontextprotocol/shared'; -import { FetchLike } from '@modelcontextprotocol/shared'; +import { ServerError } from '../../../../../core/src/index.js'; +import { FetchLike } from '../../../../../core/src/index.js'; export type ProxyEndpoints = { authorizationUrl: string; diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts index 236138949..926b2bf7d 100644 --- a/packages/server/src/server/auth/router.ts +++ b/packages/server/src/server/auth/router.ts @@ -5,7 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from './handlers/au import { revocationHandler, RevocationHandlerOptions } from './handlers/revoke.js'; import { metadataHandler } from './handlers/metadata.js'; import { OAuthServerProvider } from './provider.js'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/shared'; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../../core/src/index.js'; // Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) const allowInsecureIssuerUrl = diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index 204ba2e2b..c7562aff3 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,4 +1,4 @@ -import { AnySchema, SchemaInput } from '@modelcontextprotocol/shared'; +import { AnySchema, SchemaInput } from '../../../core/src/index.js'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); diff --git a/src/examples/shared/inMemoryEventStore.ts b/packages/server/src/server/inMemoryEventStore.ts similarity index 93% rename from src/examples/shared/inMemoryEventStore.ts rename to packages/server/src/server/inMemoryEventStore.ts index d4d02eb91..3e7dc26d4 100644 --- a/src/examples/shared/inMemoryEventStore.ts +++ b/packages/server/src/server/inMemoryEventStore.ts @@ -1,5 +1,5 @@ -import { JSONRPCMessage } from '../../types.js'; -import { EventStore } from '../../server/streamableHttp.js'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import type { EventStore } from './streamableHttp.js'; /** * Simple in-memory implementation of the EventStore interface for resumability @@ -21,7 +21,7 @@ export class InMemoryEventStore implements EventStore { */ private getStreamIdFromEventId(eventId: string): string { const parts = eventId.split('_'); - return parts.length > 0 ? parts[0] : ''; + return parts.length > 0 ? parts[0]! : ''; } /** diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 1744cc76b..455e46c9f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -14,7 +14,7 @@ import { isSchemaOptional, getLiteralValue, toJsonSchemaCompat -} from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; import { Implementation, Tool, @@ -53,13 +53,13 @@ import { assertCompleteRequestResourceTemplate, CallToolRequest, ToolExecution -} from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; import { isCompletable, getCompleter } from './completable.js'; -import { UriTemplate, Variables } from '@modelcontextprotocol/shared'; -import { RequestHandlerExtra } from '@modelcontextprotocol/shared'; -import { Transport } from '@modelcontextprotocol/shared'; +import { UriTemplate, Variables } from '../../../core/src/index.js'; +import { RequestHandlerExtra } from '../../../core/src/index.js'; +import { Transport } from '../../../core/src/index.js'; -import { validateAndWarnToolName } from '@modelcontextprotocol/shared'; +import { validateAndWarnToolName } from '../../../core/src/index.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ZodOptional } from 'zod'; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 12540d428..90b462be7 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,4 +1,10 @@ -import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/shared'; +import { + mergeCapabilities, + Protocol, + type NotificationOptions, + type ProtocolOptions, + type RequestOptions +} from '../../../core/src/index.js'; import { type ClientCapabilities, type CreateMessageRequest, @@ -41,9 +47,9 @@ import { type Request, type Notification, type Result -} from '@modelcontextprotocol/shared'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/shared'; -import type { JsonSchemaType, jsonSchemaValidator } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import { AjvJsonSchemaValidator } from '../../../core/src/index.js'; +import type { JsonSchemaType, jsonSchemaValidator } from '../../../core/src/index.js'; import { AnyObjectSchema, getObjectShape, @@ -52,10 +58,10 @@ import { SchemaOutput, type ZodV3Internal, type ZodV4Internal -} from '@modelcontextprotocol/shared'; -import { RequestHandlerExtra } from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; +import { RequestHandlerExtra } from '../../../core/src/index.js'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/shared'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../../../core/src/index.js'; export type ServerOptions = ProtocolOptions & { /** @@ -509,7 +515,7 @@ export class Server< // 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[params.messages.length - 1]; + const lastMessage = params.messages[params.messages.length - 1]!; const lastContent = Array.isArray(lastMessage.content) ? lastMessage.content : [lastMessage.content]; const hasToolResults = lastContent.some(c => c.type === 'tool_result'); diff --git a/packages/server/src/server/sse.ts b/packages/server/src/server/sse.ts index c9e618159..724c3b4cb 100644 --- a/packages/server/src/server/sse.ts +++ b/packages/server/src/server/sse.ts @@ -1,10 +1,10 @@ import { randomUUID } from 'node:crypto'; import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '@modelcontextprotocol/shared'; -import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '@modelcontextprotocol/shared'; +import { Transport } from '../../../core/src/index.js'; +import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../../../core/src/index.js'; import getRawBody from 'raw-body'; import contentType from 'content-type'; -import { AuthInfo } from '@modelcontextprotocol/shared'; +import { AuthInfo } from '../../../core/src/index.js'; import { URL } from 'node:url'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 6fd447bc2..7fd0472bc 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,8 +1,8 @@ import process from 'node:process'; import { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/shared'; -import { JSONRPCMessage } from '@modelcontextprotocol/shared'; -import { Transport } from '@modelcontextprotocol/shared'; +import { ReadBuffer, serializeMessage } from '../../../core/src/index.js'; +import { JSONRPCMessage } from '../../../core/src/index.js'; +import { Transport } from '../../../core/src/index.js'; /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 5cb880efe..6e10827dd 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -1,5 +1,5 @@ import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '@modelcontextprotocol/shared'; +import { Transport } from '../../../core/src/index.js'; import { MessageExtraInfo, RequestInfo, @@ -12,11 +12,11 @@ import { SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isJSONRPCErrorResponse -} from '@modelcontextprotocol/shared'; +} from '../../../core/src/index.js'; import getRawBody from 'raw-body'; import contentType from 'content-type'; import { randomUUID } from 'node:crypto'; -import { AuthInfo } from '@modelcontextprotocol/shared'; +import { AuthInfo } from '../../../core/src/index.js'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/test/.gitkeep b/packages/server/test/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/server/test/server/__fixtures__/zodTestMatrix.ts b/packages/server/test/server/__fixtures__/zodTestMatrix.ts new file mode 100644 index 000000000..fc4ee63db --- /dev/null +++ b/packages/server/test/server/__fixtures__/zodTestMatrix.ts @@ -0,0 +1,22 @@ +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; + +// Shared Zod namespace type that exposes the common surface area used in tests. +export type ZNamespace = typeof z3 & typeof z4; + +export const zodTestMatrix = [ + { + zodVersionLabel: 'Zod v3', + z: z3 as ZNamespace, + isV3: true as const, + isV4: false as const + }, + { + zodVersionLabel: 'Zod v4', + z: z4 as ZNamespace, + isV3: false as const, + isV4: true as const + } +] as const; + +export type ZodMatrixEntry = (typeof zodTestMatrix)[number]; diff --git a/test/server/auth/handlers/authorize.test.ts b/packages/server/test/server/auth/handlers/authorize.test.ts similarity index 93% rename from test/server/auth/handlers/authorize.test.ts rename to packages/server/test/server/auth/handlers/authorize.test.ts index 0f831ae7d..a718f8e53 100644 --- a/test/server/auth/handlers/authorize.test.ts +++ b/packages/server/test/server/auth/handlers/authorize.test.ts @@ -1,11 +1,11 @@ import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import express, { Response } from 'express'; import supertest from 'supertest'; -import { AuthInfo } from '../../../../src/server/auth/types.js'; -import { InvalidTokenError } from '../../../../src/server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk-core'; describe('Authorization Handler', () => { // Mock client data @@ -132,7 +132,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.origin + location.pathname).toBe('https://example.com/callback'); }); @@ -169,7 +169,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.origin + location.pathname).toBe('https://example.com/callback'); }); }); @@ -185,7 +185,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.get('error')).toBe('invalid_request'); }); @@ -199,7 +199,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.get('error')).toBe('invalid_request'); }); @@ -213,7 +213,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.get('error')).toBe('invalid_request'); }); }); @@ -257,7 +257,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.origin + location.pathname).toBe('https://example.com/callback'); expect(location.searchParams.get('code')).toBe('mock_auth_code'); expect(location.searchParams.get('state')).toBe('xyz789'); @@ -274,7 +274,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.get('state')).toBe('state-value-123'); }); @@ -287,7 +287,7 @@ describe('Authorization Handler', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.has('code')).toBe(true); }); }); diff --git a/test/server/auth/handlers/metadata.test.ts b/packages/server/test/server/auth/handlers/metadata.test.ts similarity index 97% rename from test/server/auth/handlers/metadata.test.ts rename to packages/server/test/server/auth/handlers/metadata.test.ts index 2eb7693f2..1472548d1 100644 --- a/test/server/auth/handlers/metadata.test.ts +++ b/packages/server/test/server/auth/handlers/metadata.test.ts @@ -1,5 +1,5 @@ import { metadataHandler } from '../../../../src/server/auth/handlers/metadata.js'; -import { OAuthMetadata } from '../../../../src/shared/auth.js'; +import type { OAuthMetadata } from '@modelcontextprotocol/sdk-core'; import express from 'express'; import supertest from 'supertest'; diff --git a/test/server/auth/handlers/register.test.ts b/packages/server/test/server/auth/handlers/register.test.ts similarity index 99% rename from test/server/auth/handlers/register.test.ts rename to packages/server/test/server/auth/handlers/register.test.ts index 03fde46d2..a769ba84d 100644 --- a/test/server/auth/handlers/register.test.ts +++ b/packages/server/test/server/auth/handlers/register.test.ts @@ -1,6 +1,6 @@ import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from '../../../../src/server/auth/handlers/register.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../../src/shared/auth.js'; +import type { OAuthClientInformationFull, OAuthClientMetadata } from '@modelcontextprotocol/sdk-core'; import express from 'express'; import supertest from 'supertest'; import { MockInstance } from 'vitest'; diff --git a/test/server/auth/handlers/revoke.test.ts b/packages/server/test/server/auth/handlers/revoke.test.ts similarity index 97% rename from test/server/auth/handlers/revoke.test.ts rename to packages/server/test/server/auth/handlers/revoke.test.ts index 69cac83d9..7c59f9334 100644 --- a/test/server/auth/handlers/revoke.test.ts +++ b/packages/server/test/server/auth/handlers/revoke.test.ts @@ -1,11 +1,11 @@ import { revocationHandler, RevocationHandlerOptions } from '../../../../src/server/auth/handlers/revoke.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import express, { Response } from 'express'; import supertest from 'supertest'; -import { AuthInfo } from '../../../../src/server/auth/types.js'; -import { InvalidTokenError } from '../../../../src/server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk-core'; import { MockInstance } from 'vitest'; describe('Revocation Handler', () => { diff --git a/test/server/auth/handlers/token.test.ts b/packages/server/test/server/auth/handlers/token.test.ts similarity index 98% rename from test/server/auth/handlers/token.test.ts rename to packages/server/test/server/auth/handlers/token.test.ts index 658142b4b..dc7a2f813 100644 --- a/test/server/auth/handlers/token.test.ts +++ b/packages/server/test/server/auth/handlers/token.test.ts @@ -1,12 +1,12 @@ import { tokenHandler, TokenHandlerOptions } from '../../../../src/server/auth/handlers/token.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import express, { Response } from 'express'; import supertest from 'supertest'; import * as pkceChallenge from 'pkce-challenge'; -import { InvalidGrantError, InvalidTokenError } from '../../../../src/server/auth/errors.js'; -import { AuthInfo } from '../../../../src/server/auth/types.js'; +import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk-core'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; import { ProxyOAuthServerProvider } from '../../../../src/server/auth/providers/proxyProvider.js'; import { type Mock } from 'vitest'; diff --git a/test/server/auth/middleware/allowedMethods.test.ts b/packages/server/test/server/auth/middleware/allowedMethods.test.ts similarity index 100% rename from test/server/auth/middleware/allowedMethods.test.ts rename to packages/server/test/server/auth/middleware/allowedMethods.test.ts diff --git a/test/server/auth/middleware/bearerAuth.test.ts b/packages/server/test/server/auth/middleware/bearerAuth.test.ts similarity index 99% rename from test/server/auth/middleware/bearerAuth.test.ts rename to packages/server/test/server/auth/middleware/bearerAuth.test.ts index 68162be9b..71f575dbb 100644 --- a/test/server/auth/middleware/bearerAuth.test.ts +++ b/packages/server/test/server/auth/middleware/bearerAuth.test.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; import { Mock } from 'vitest'; import { requireBearerAuth } from '../../../../src/server/auth/middleware/bearerAuth.js'; -import { AuthInfo } from '../../../../src/server/auth/types.js'; -import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../../../../src/server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '@modelcontextprotocol/sdk-core'; import { OAuthTokenVerifier } from '../../../../src/server/auth/provider.js'; -import { createExpressResponseMock } from '../../../helpers/http.js'; +import { createExpressResponseMock } from '../../../../../integration/test/helpers/http.js'; // Mock verifier const mockVerifyAccessToken = vi.fn(); diff --git a/test/server/auth/middleware/clientAuth.test.ts b/packages/server/test/server/auth/middleware/clientAuth.test.ts similarity index 98% rename from test/server/auth/middleware/clientAuth.test.ts rename to packages/server/test/server/auth/middleware/clientAuth.test.ts index 50cc1d907..48eb82d32 100644 --- a/test/server/auth/middleware/clientAuth.test.ts +++ b/packages/server/test/server/auth/middleware/clientAuth.test.ts @@ -1,6 +1,6 @@ import { authenticateClient, ClientAuthenticationMiddlewareOptions } from '../../../../src/server/auth/middleware/clientAuth.js'; import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull } from '../../../../src/shared/auth.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; import express from 'express'; import supertest from 'supertest'; diff --git a/test/server/auth/providers/proxyProvider.test.ts b/packages/server/test/server/auth/providers/proxyProvider.test.ts similarity index 96% rename from test/server/auth/providers/proxyProvider.test.ts rename to packages/server/test/server/auth/providers/proxyProvider.test.ts index 40fb55d57..9ae4aab1b 100644 --- a/test/server/auth/providers/proxyProvider.test.ts +++ b/packages/server/test/server/auth/providers/proxyProvider.test.ts @@ -1,10 +1,10 @@ import { Response } from 'express'; import { ProxyOAuthServerProvider, ProxyOptions } from '../../../../src/server/auth/providers/proxyProvider.js'; -import { AuthInfo } from '../../../../src/server/auth/types.js'; -import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js'; -import { ServerError } from '../../../../src/server/auth/errors.js'; -import { InvalidTokenError } from '../../../../src/server/auth/errors.js'; -import { InsufficientScopeError } from '../../../../src/server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk-core'; +import { ServerError } from '@modelcontextprotocol/sdk-core'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk-core'; +import { InsufficientScopeError } from '@modelcontextprotocol/sdk-core'; import { type Mock } from 'vitest'; describe('Proxy OAuth Server Provider', () => { @@ -184,7 +184,7 @@ describe('Proxy OAuth Server Provider', () => { const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); const fetchCall = (global.fetch as Mock).mock.calls[0]; - const body = fetchCall[1].body as string; + const body = fetchCall![1].body as string; expect(body).not.toContain('resource='); expect(tokens).toEqual(mockTokenResponse); }); diff --git a/test/server/auth/router.test.ts b/packages/server/test/server/auth/router.test.ts similarity index 98% rename from test/server/auth/router.test.ts rename to packages/server/test/server/auth/router.test.ts index 521c650c4..41f14ba8f 100644 --- a/test/server/auth/router.test.ts +++ b/packages/server/test/server/auth/router.test.ts @@ -1,11 +1,11 @@ import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from '../../../src/server/auth/router.js'; import { OAuthServerProvider, AuthorizationParams } from '../../../src/server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../../src/server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../../src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import express, { Response } from 'express'; import supertest from 'supertest'; -import { AuthInfo } from '../../../src/server/auth/types.js'; -import { InvalidTokenError } from '../../../src/server/auth/errors.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { InvalidTokenError } from '@modelcontextprotocol/sdk-core'; describe('MCP Auth Router', () => { // Setup mock provider with full capabilities @@ -295,7 +295,7 @@ describe('MCP Auth Router', () => { }); expect(response.status).toBe(302); - const location = new URL(response.header.location); + const location = new URL(response.header.location!); expect(location.searchParams.has('code')).toBe(true); }); diff --git a/test/server/completable.test.ts b/packages/server/test/server/completable.test.ts similarity index 95% rename from test/server/completable.test.ts rename to packages/server/test/server/completable.test.ts index 3f917a492..aed952d98 100644 --- a/test/server/completable.test.ts +++ b/packages/server/test/server/completable.test.ts @@ -1,5 +1,5 @@ import { completable, getCompleter } from '../../src/server/completable.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/test/server/sse.test.ts b/packages/server/test/server/sse.test.ts similarity index 99% rename from test/server/sse.test.ts rename to packages/server/test/server/sse.test.ts index 4686f2ba9..b6f474074 100644 --- a/test/server/sse.test.ts +++ b/packages/server/test/server/sse.test.ts @@ -4,9 +4,9 @@ import { type Mocked } from 'vitest'; import { SSEServerTransport } from '../../src/server/sse.js'; import { McpServer } from '../../src/server/mcp.js'; import { createServer, type Server } from 'node:http'; -import { CallToolResult, JSONRPCMessage } from '../../src/types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; -import { listenOnRandomPort } from '../helpers/http.js'; +import { CallToolResult, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { listenOnRandomPort } from '../../../integration/test/helpers/http.js'; const createMockResponse = () => { const res = { @@ -142,7 +142,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const baseUrl = await listenOnRandomPort(server); const addr = server.address(); - const port = typeof addr === 'string' ? new URL(baseUrl).port : (addr as any).port; + const port = typeof addr === 'string' ? new URL(baseUrl).port : (addr as unknown as { port: number }).port; return { server, transport, mcpServer, baseUrl, sessionId, serverPort: Number(port) }; } diff --git a/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts similarity index 90% rename from test/server/stdio.test.ts rename to packages/server/test/server/stdio.test.ts index 86379c8a6..8a173600e 100644 --- a/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -1,6 +1,6 @@ import { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../../src/shared/stdio.js'; -import { JSONRPCMessage } from '../../src/types.js'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import { StdioServerTransport } from '../../src/server/stdio.js'; let input: Readable; @@ -93,8 +93,8 @@ test('should read multiple messages', async () => { }; }); - input.push(serializeMessage(messages[0])); - input.push(serializeMessage(messages[1])); + input.push(serializeMessage(messages[0]!)); + input.push(serializeMessage(messages[1]!)); await server.start(); await finished; diff --git a/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts similarity index 99% rename from test/server/streamableHttp.test.ts rename to packages/server/test/server/streamableHttp.test.ts index 0161d82fb..c64fd7313 100644 --- a/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -3,10 +3,10 @@ import { AddressInfo, createServer as netCreateServer } from 'node:net'; import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from '../../src/server/streamableHttp.js'; import { McpServer } from '../../src/server/mcp.js'; -import { CallToolResult, JSONRPCMessage } from '../../src/types.js'; -import { AuthInfo } from '../../src/server/auth/types.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; -import { listenOnRandomPort } from '../helpers/http.js'; +import { CallToolResult, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { listenOnRandomPort } from '../../../integration/test/helpers/http.js'; async function getFreePort() { return new Promise(res => { @@ -1285,7 +1285,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { send: (eventId: EventId, message: JSONRPCMessage) => Promise; } ): Promise { - const streamId = lastEventId.split('_')[0]; + 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()) { @@ -1361,7 +1361,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(idMatch).toBeTruthy(); // Verify the event was stored - const eventId = idMatch![1]; + const eventId = idMatch![1]!; expect(storedEvents.has(eventId)).toBe(true); const storedEvent = storedEvents.get(eventId); expect(eventId.startsWith('_GET_stream')).toBe(true); @@ -1395,7 +1395,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Extract the event ID const idMatch = text.match(/id: ([^\n]+)/); expect(idMatch).toBeTruthy(); - const firstEventId = idMatch![1]; + const firstEventId = idMatch![1]!; // Send a second notification await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); @@ -1450,7 +1450,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Extract the event ID const idMatch = text.match(/id: ([^\n]+)/); expect(idMatch).toBeTruthy(); - const lastEventId = idMatch![1]; + const lastEventId = idMatch![1]!; // Close the SSE stream to simulate a disconnect await reader!.cancel(); @@ -1613,7 +1613,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { { send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } ): Promise { const event = storedEvents.get(lastEventId); - const streamId = event?.streamId || lastEventId.split('::')[0]; + 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) { @@ -2214,7 +2214,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const text = new TextDecoder().decode(value); const idMatch = text.match(/id: ([^\n]+)/); expect(idMatch).toBeTruthy(); - const lastEventId = idMatch![1]; + const lastEventId = idMatch![1]!; // Call the tool to close the standalone SSE stream const toolCallRequest: JSONRPCMessage = { @@ -2285,7 +2285,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { // Verify we received the notification that was sent while disconnected expect(allText).toContain('Missed while disconnected'); - }, 10000); + }, 15000); }); // Test onsessionclosed callback diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 84520f682..d42dc849f 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -3,10 +3,11 @@ "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { - "baseUrl": ".", "paths": { - "@modelcontextprotocol/shared": ["node_modules/@modelcontextprotocol/shared/src/index.ts"], - "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "*": ["./*"], + "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], + "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], + "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] } } } diff --git a/packages/server/vitest.config.js b/packages/server/vitest.config.js new file mode 100644 index 000000000..cd4859e3f --- /dev/null +++ b/packages/server/vitest.config.js @@ -0,0 +1,16 @@ +import baseConfig from '../../common/vitest-config/vitest.config.js'; +import { mergeConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export default mergeConfig(baseConfig, { + resolve: { + alias: { + // Use workspace source files instead of built dist/ for tests + '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts') + } + } +}); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts deleted file mode 100644 index 496fca320..000000000 --- a/packages/server/vitest.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/packages/server/vitest.setup.ts b/packages/server/vitest.setup.ts deleted file mode 100644 index 2c6606b9c..000000000 --- a/packages/server/vitest.setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import '../../vitest.setup'; - - diff --git a/packages/shared/src/shared/metadataUtils.ts b/packages/shared/src/shared/metadataUtils.ts deleted file mode 100644 index 18f84a4c9..000000000 --- a/packages/shared/src/shared/metadataUtils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { BaseMetadata } from '../types.js'; - -/** - * Utilities for working with BaseMetadata objects. - */ - -/** - * Gets the display name for an object with BaseMetadata. - * For tools, the precedence is: title → 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/shared/src/shared/responseMessage.ts b/packages/shared/src/shared/responseMessage.ts deleted file mode 100644 index 6fefcf1f6..000000000 --- a/packages/shared/src/shared/responseMessage.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Result, Task, McpError } from '../types.js'; - -/** - * Base message type - */ -export interface BaseResponseMessage { - type: string; -} - -/** - * Task status update message - */ -export interface TaskStatusMessage extends BaseResponseMessage { - type: 'taskStatus'; - task: Task; -} - -/** - * Task created message (first message for task-augmented requests) - */ -export interface TaskCreatedMessage extends BaseResponseMessage { - type: 'taskCreated'; - task: Task; -} - -/** - * Final result message (terminal) - */ -export interface ResultMessage extends BaseResponseMessage { - type: 'result'; - result: T; -} - -/** - * Error message (terminal) - */ -export interface ErrorMessage extends BaseResponseMessage { - type: 'error'; - error: McpError; -} - -/** - * Union type representing all possible messages that can be yielded during request processing. - * Note: Progress notifications are handled through the existing onprogress callback mechanism. - * 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; - -export async function toArrayAsync>(it: T): Promise[]> { - const arr: AsyncGeneratorValue[] = []; - for await (const o of it) { - arr.push(o as AsyncGeneratorValue); - } - - return arr; -} - -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/shared/src/shared/uriTemplate.ts b/packages/shared/src/shared/uriTemplate.ts deleted file mode 100644 index 1dd57f56f..000000000 --- a/packages/shared/src/shared/uriTemplate.ts +++ /dev/null @@ -1,287 +0,0 @@ -// Claude-authored implementation of RFC 6570 URI Templates - -export type Variables = Record; - -const MAX_TEMPLATE_LENGTH = 1000000; // 1MB -const MAX_VARIABLE_LENGTH = 1000000; // 1MB -const MAX_TEMPLATE_EXPRESSIONS = 10000; -const MAX_REGEX_LENGTH = 1000000; // 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 - if ((part.operator === '?' || part.operator === '&') && hasQueryParam) { - result += expanded.replace('?', '&'); - } else { - result += expanded; - } - - if (part.operator === '?' || part.operator === '&') { - hasQueryParam = true; - } - } - - return result; - } - - private escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - 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 = '\\.([^/,]+)'; - 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 (let i = 0; i < names.length; i++) { - const { name, exploded } = names[i]; - const value = match[i + 1]; - const cleanName = name.replace('*', ''); - - if (exploded && value.includes(',')) { - result[cleanName] = value.split(','); - } else { - result[cleanName] = value; - } - } - - return result; - } -} diff --git a/packages/shared/src/types/spec.types.ts b/packages/shared/src/types/spec.types.ts deleted file mode 100644 index 07a1cceff..000000000 --- a/packages/shared/src/types/spec.types.ts +++ /dev/null @@ -1,2587 +0,0 @@ -/** - * 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: 35fa160caf287a9c48696e3ae452c0645c713669 - * - * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. - * To update this file, run: npm run fetch:spec-types - *//* 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"; - -/** - * 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 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?: TaskMetadata; -} -/** - * Common params for any request. - * - * @internal - */ -export interface RequestParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { - /** - * 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?: ProgressToken; - [key: string]: unknown; - }; -} - -/** @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 }; -} - -/** @internal */ -export interface NotificationParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** @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 }; -} - -/** - * @category Common Types - */ -export interface Result { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * @category Common Types - */ -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. - */ -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; - -// 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. - * - * @internal - */ -export interface URLElicitationRequiredError - extends Omit { - error: Error & { - code: typeof URL_ELICITATION_REQUIRED; - data: { - elicitations: ElicitRequestURLParams[]; - [key: string]: unknown; - }; - }; -} - -/* Empty result */ -/** - * A response that indicates success but carries no data. - * - * @category Common Types - */ -export type EmptyResult = Result; - -/* Cancellation */ -/** - * Parameters for a `notifications/cancelled` notification. - * - * @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 `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 `tasks/cancel` request instead of this notification. - * - * @category `notifications/cancelled` - */ -export interface CancelledNotification extends JSONRPCNotification { - method: "notifications/cancelled"; - params: CancelledNotificationParams; -} - -/* Initialization */ -/** - * Parameters for an `initialize` request. - * - * @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. - * - * @category `initialize` - */ -export interface InitializeRequest extends JSONRPCRequest { - method: "initialize"; - params: InitializeRequestParams; -} - -/** - * After receiving an initialize request from the client, the server sends this response. - * - * @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. - * - * 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?: string; -} - -/** - * This notification is sent from the client to the server after initialization has finished. - * - * @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]: object }; - /** - * Present if the client supports listing roots. - */ - roots?: { - /** - * Whether the client supports notifications for changes to the roots list. - */ - listChanged?: boolean; - }; - /** - * Present if the client supports sampling from an LLM. - */ - sampling?: { - /** - * Whether the client supports context inclusion via includeContext parameter. - * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). - */ - context?: object; - /** - * Whether the client supports tool use via tools and toolChoice parameters. - */ - tools?: object; - }; - /** - * Present if the client supports elicitation from the server. - */ - elicitation?: { form?: object; url?: object }; - - /** - * Present if the client supports task-augmented requests. - */ - tasks?: { - /** - * Whether this client supports tasks/list. - */ - list?: object; - /** - * Whether this client supports tasks/cancel. - */ - cancel?: object; - /** - * 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?: object; - }; - /** - * Task support for elicitation-related requests. - */ - elicitation?: { - /** - * Whether the client supports task-augmented elicitation/create requests. - */ - create?: object; - }; - }; - }; -} - -/** - * 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]: object }; - /** - * Present if the server supports sending log messages to the client. - */ - logging?: object; - /** - * Present if the server supports argument autocompletion suggestions. - */ - completions?: object; - /** - * Present if the server offers any prompt templates. - */ - prompts?: { - /** - * Whether this server supports notifications for changes to the prompt list. - */ - listChanged?: boolean; - }; - /** - * Present if the server offers any resources to read. - */ - 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. - */ - 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 tasks/list. - */ - list?: object; - /** - * Whether this server supports tasks/cancel. - */ - cancel?: object; - /** - * Specifies which request types can be augmented with tasks. - */ - requests?: { - /** - * Task support for tool-related requests. - */ - tools?: { - /** - * Whether the server supports task-augmented tools/call requests. - */ - call?: object; - }; - }; - }; -} - -/** - * 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 takes 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 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 { - 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. - * - * @category `ping` - */ -export interface PingRequest extends JSONRPCRequest { - method: "ping"; - params?: RequestParams; -} - -/* Progress notifications */ - -/** - * Parameters for a `notifications/progress` notification. - * - * @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. - * - * @category `notifications/progress` - */ -export interface ProgressNotification extends JSONRPCNotification { - method: "notifications/progress"; - params: ProgressNotificationParams; -} - -/* Pagination */ -/** - * Common parameters for paginated requests. - * - * @internal - */ -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. - * - * @category `resources/list` - */ -export interface ListResourcesRequest extends PaginatedRequest { - method: "resources/list"; -} - -/** - * The server's response to a resources/list request from the client. - * - * @category `resources/list` - */ -export interface ListResourcesResult extends PaginatedResult { - resources: Resource[]; -} - -/** - * Sent from the client to request a list of resource templates the server has. - * - * @category `resources/templates/list` - */ -export interface ListResourceTemplatesRequest extends PaginatedRequest { - method: "resources/templates/list"; -} - -/** - * The server's response to a resources/templates/list request from the client. - * - * @category `resources/templates/list` - */ -export interface ListResourceTemplatesResult extends PaginatedResult { - resourceTemplates: ResourceTemplate[]; -} - -/** - * Common parameters when working with resources. - * - * @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. - * - * @category `resources/read` - */ -export interface ReadResourceRequest extends JSONRPCRequest { - method: "resources/read"; - params: ReadResourceRequestParams; -} - -/** - * The server's response to a resources/read request from the client. - * - * @category `resources/read` - */ -export interface ReadResourceResult extends Result { - contents: (TextResourceContents | BlobResourceContents)[]; -} - -/** - * 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. - * - * @category `notifications/resources/list_changed` - */ -export interface ResourceListChangedNotification extends JSONRPCNotification { - method: "notifications/resources/list_changed"; - params?: NotificationParams; -} - -/** - * Parameters for a `resources/subscribe` request. - * - * @category `resources/subscribe` - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface SubscribeRequestParams extends ResourceRequestParams {} - -/** - * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. - * - * @category `resources/subscribe` - */ -export interface SubscribeRequest extends JSONRPCRequest { - method: "resources/subscribe"; - params: SubscribeRequestParams; -} - -/** - * 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. - * - * @category `resources/unsubscribe` - */ -export interface UnsubscribeRequest extends JSONRPCRequest { - method: "resources/unsubscribe"; - params: UnsubscribeRequestParams; -} - -/** - * Parameters for a `notifications/resources/updated` notification. - * - * @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 resources/subscribe request. - * - * @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. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * @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; -} - -/** - * @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. - * - * @category `prompts/list` - */ -export interface ListPromptsRequest extends PaginatedRequest { - method: "prompts/list"; -} - -/** - * The server's response to a prompts/list request from the client. - * - * @category `prompts/list` - */ -export interface ListPromptsResult extends PaginatedResult { - prompts: Prompt[]; -} - -/** - * Parameters for a `prompts/get` request. - * - * @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. - * - * @category `prompts/get` - */ -export interface GetPromptRequest extends JSONRPCRequest { - method: "prompts/get"; - params: GetPromptRequestParams; -} - -/** - * The server's response to a prompts/get request from the client. - * - * @category `prompts/get` - */ -export interface GetPromptResult extends Result { - /** - * An optional description for the prompt. - */ - description?: string; - messages: PromptMessage[]; -} - -/** - * 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[]; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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 `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 `resources/list` requests. - * - * @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. - * - * @category Content - */ -export interface EmbeddedResource { - type: "resource"; - resource: TextResourceContents | BlobResourceContents; - - /** - * Optional annotations for the client. - */ - annotations?: Annotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} -/** - * 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. - * - * @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. - * - * @category `tools/list` - */ -export interface ListToolsRequest extends PaginatedRequest { - method: "tools/list"; -} - -/** - * The server's response to a tools/list request from the client. - * - * @category `tools/list` - */ -export interface ListToolsResult extends PaginatedResult { - tools: Tool[]; -} - -/** - * The server's response to a tool call. - * - * @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; -} - -/** - * Parameters for a `tools/call` request. - * - * @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. - * - * @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. - * - * @category `notifications/tools/list_changed` - */ -export interface ToolListChangedNotification extends JSONRPCNotification { - method: "notifications/tools/list_changed"; - params?: NotificationParams; -} - -/** - * Additional properties describing a 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. - * - * @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]: object }; - 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 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]: object }; - required?: string[]; - }; - - /** - * Optional additional tool information. - * - * Display name precedence order is: title, annotations.title, then name. - */ - annotations?: ToolAnnotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/* 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. - */ - ttl: number | null; - - /** - * Suggested polling interval in milliseconds. - */ - pollInterval?: number; -} - -/** - * A response to a task-augmented request. - * - * @category `tasks` - */ -export interface CreateTaskResult extends Result { - task: Task; -} - -/** - * 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 response to a tasks/get request. - * - * @category `tasks/get` - */ -export type GetTaskResult = Result & Task; - -/** - * 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 response to a tasks/result request. - * The structure matches the result type of the original request. - * For example, a tools/call task would return the CallToolResult structure. - * - * @category `tasks/result` - */ -export interface GetTaskPayloadResult extends Result { - [key: string]: unknown; -} - -/** - * 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 response to a tasks/cancel request. - * - * @category `tasks/cancel` - */ -export type CancelTaskResult = Result & Task; - -/** - * A request to retrieve a list of tasks. - * - * @category `tasks/list` - */ -export interface ListTasksRequest extends PaginatedRequest { - method: "tasks/list"; -} - -/** - * The response to a tasks/list request. - * - * @category `tasks/list` - */ -export interface ListTasksResult extends PaginatedResult { - tasks: Task[]; -} - -/** - * 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. - * - * @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 notifications/message. - */ - level: LoggingLevel; -} - -/** - * A request from the client to the server, to enable or adjust logging. - * - * @category `logging/setLevel` - */ -export interface SetLevelRequest extends JSONRPCRequest { - method: "logging/setLevel"; - params: SetLevelRequestParams; -} - -/** - * Parameters for a `notifications/message` notification. - * - * @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. - * - * @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. - * - * @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 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?: object; - /** - * 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?: Tool[]; - /** - * 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?: 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. - * - * @category `sampling/createMessage` - */ -export interface CreateMessageRequest extends JSONRPCRequest { - method: "sampling/createMessage"; - params: CreateMessageRequestParams; -} - -/** - * The client's response to a sampling/createMessage request from the server. - * 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. - * - * @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; -} - -/** - * Describes a message issued to or received from an LLM API. - * - * @category `sampling/createMessage` - */ -export interface SamplingMessage { - role: Role; - content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} -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. - * - * @category Content - */ -export interface TextContent { - type: "text"; - - /** - * The text content of the message. - */ - text: string; - - /** - * Optional annotations for the client. - */ - annotations?: Annotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * An image provided to or from an LLM. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * Audio provided to or from an LLM. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * A request from the assistant to call a tool. - * - * @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. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * The result of a tool use, provided by the user back to the assistant. - * - * @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 ToolUseContent. - */ - toolUseId: string; - - /** - * The unstructured result content of the tool use. - * - * This has the same format as 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 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. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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. - * - * @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` - */ -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. - * - * @category `completion/complete` - */ -export interface CompleteRequest extends JSONRPCRequest { - method: "completion/complete"; - params: CompleteRequestParams; -} - -/** - * The server's response to a completion/complete request - * - * @category `completion/complete` - */ -export interface CompleteResult extends Result { - completion: { - /** - * An array of completion values. Must not exceed 100 items. - */ - 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 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. - * - * @category `roots/list` - */ -export interface ListRootsRequest extends JSONRPCRequest { - method: "roots/list"; - params?: RequestParams; -} - -/** - * The client's response to a roots/list request from the server. - * This result contains an array of Root objects, each representing a root directory - * or file that the server can operate on. - * - * @category `roots/list` - */ -export interface ListRootsResult extends Result { - roots: Root[]; -} - -/** - * Represents a root directory or file that the server can operate on. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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 ListRootsRequest. - * - * @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. - * - * @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. - * - * @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. - * - * @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; - -/** - * @category `elicitation/create` - */ -export interface StringSchema { - type: "string"; - title?: string; - description?: string; - minLength?: number; - maxLength?: number; - format?: "email" | "uri" | "date" | "date-time"; - default?: string; -} - -/** - * @category `elicitation/create` - */ -export interface NumberSchema { - type: "number" | "integer"; - title?: string; - description?: string; - minimum?: number; - maximum?: number; - default?: number; -} - -/** - * @category `elicitation/create` - */ -export interface BooleanSchema { - type: "boolean"; - title?: string; - description?: string; - default?: boolean; -} - -/** - * Schema for single-selection enumeration without display titles for options. - * - * @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. - * - * @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. - * - * @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. - * - * @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 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 client's response to an elicitation request. - * - * @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 decline 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[] }; -} - -/** - * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. - * - * @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 - | ListToolsResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json deleted file mode 100644 index 9253559a0..000000000 --- a/packages/shared/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "baseUrl": ".", - "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/shared/vitest.config.ts b/packages/shared/vitest.config.ts deleted file mode 100644 index 496fca320..000000000 --- a/packages/shared/vitest.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; - -export default baseConfig; diff --git a/packages/shared/vitest.setup.ts b/packages/shared/vitest.setup.ts deleted file mode 100644 index 2c6606b9c..000000000 --- a/packages/shared/vitest.setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import '../../vitest.setup'; - - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 849b432cb..3f37b0fb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,9 +139,6 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 - '@modelcontextprotocol/tsconfig': - specifier: workspace:^ - version: link:../tsconfig eslint: specifier: ^9.8.0 version: 9.39.1 @@ -176,9 +173,9 @@ importers: packages/client: dependencies: - '@modelcontextprotocol/shared': + '@modelcontextprotocol/sdk-core': specifier: workspace:^ - version: link:../shared + version: link:../core ajv: specifier: ^8.17.1 version: 8.17.1 @@ -301,30 +298,8 @@ importers: specifier: ^8.18.0 version: 8.18.3 - packages/examples: - dependencies: - '@modelcontextprotocol/sdk-client': - specifier: workspace:^ - version: link:../client - '@modelcontextprotocol/sdk-server': - specifier: workspace:^ - version: link:../server - 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 - - packages/server: + packages/core: dependencies: - '@modelcontextprotocol/shared': - specifier: workspace:^ - version: link:../shared ajv: specifier: ^8.17.1 version: 8.17.1 @@ -447,11 +422,51 @@ importers: specifier: ^8.18.0 version: 8.18.3 - packages/shared: + packages/examples: dependencies: + '@modelcontextprotocol/sdk-client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/sdk-server': + specifier: workspace:^ + version: link:../server + 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 + + packages/integration: + devDependencies: + '@modelcontextprotocol/eslint-config': + specifier: workspace:^ + version: link:../../common/eslint-config + '@modelcontextprotocol/sdk-client': + specifier: workspace:^ + version: link:../client + '@modelcontextprotocol/sdk-core': + specifier: workspace:^ + version: link:../core + '@modelcontextprotocol/sdk-server': + specifier: workspace:^ + version: link:../server + '@modelcontextprotocol/tsconfig': + specifier: workspace:^ + version: link:../../common/tsconfig + '@modelcontextprotocol/vitest-config': + specifier: workspace:^ + version: link:../../common/vitest-config + + packages/server: + dependencies: + '@modelcontextprotocol/sdk-core': + specifier: workspace:^ + version: link:../core ajv: specifier: ^8.17.1 version: 8.17.1 @@ -507,6 +522,9 @@ importers: '@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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eeebf279e..1ce7552be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,7 +2,7 @@ packages: - packages/**/* - common/**/* -catalog: +catalog: typescript: ^5.9.3 enableGlobalVirtualStore: false diff --git a/src/__mocks__/pkce-challenge.ts b/src/__mocks__/pkce-challenge.ts deleted file mode 100644 index 3dfec41f9..000000000 --- a/src/__mocks__/pkce-challenge.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default function pkceChallenge() { - return { - code_verifier: 'test_verifier', - code_challenge: 'test_challenge' - }; -} diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts deleted file mode 100644 index f3908d2c2..000000000 --- a/src/client/auth-extensions.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * OAuth provider extensions for specialized authentication flows. - * - * This module provides ready-to-use OAuthClientProvider implementations - * for common machine-to-machine authentication scenarios. - */ - -import type { JWK } from 'jose'; -import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js'; -import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; - -/** - * Helper to produce a private_key_jwt client authentication function. - * - * Usage: - * const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? }); - * // 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 (typeof globalThis.crypto === 'undefined') { - throw new TypeError( - 'crypto is not available, please ensure you add 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) { - if (alg.startsWith('HS')) { - key = options.privateKey; - } else { - // Assume PKCS#8 DER in Uint8Array for asymmetric algorithms - key = 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 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; -} - -/** - * 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 - * 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' - }; - } - - 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 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; -} - -/** - * 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 (RFC 7523 Section 2.2). - * - * @example - * 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' - }; - this.addClientAuthentication = createPrivateKeyJwtAuth({ - issuer: options.clientId, - subject: options.clientId, - privateKey: options.privateKey, - alg: options.algorithm, - lifetimeSeconds: options.jwtLifetimeSeconds - }); - } - - 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 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; -} - -/** - * OAuth provider for client_credentials grant with a static private_key_jwt assertion. - * - * This provider mirrors {@link 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' - }; - - 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; - } -} diff --git a/src/client/auth.ts b/src/client/auth.ts deleted file mode 100644 index 4c82b5114..000000000 --- a/src/client/auth.ts +++ /dev/null @@ -1,1298 +0,0 @@ -import pkceChallenge from 'pkce-challenge'; -import { LATEST_PROTOCOL_VERSION } from '../types.js'; -import { - OAuthClientMetadata, - OAuthClientInformation, - OAuthClientInformationMixed, - OAuthTokens, - OAuthMetadata, - OAuthClientInformationFull, - OAuthProtectedResourceMetadata, - OAuthErrorResponseSchema, - AuthorizationServerMetadata, - OpenIdProviderDiscoveryMetadataSchema -} from '../shared/auth.js'; -import { - OAuthClientInformationFullSchema, - OAuthMetadataSchema, - OAuthProtectedResourceMetadataSchema, - OAuthTokensSchema -} from '../shared/auth.js'; -import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; -import { - InvalidClientError, - InvalidClientMetadataError, - InvalidGrantError, - OAUTH_ERRORS, - OAuthError, - ServerError, - UnauthorizedClientError -} from '../server/auth/errors.js'; -import { FetchLike } from '../shared/transport.js'; - -/** - * Function type for adding client authentication to token requests. - */ -export type AddClientAuthentication = ( - headers: Headers, - params: URLSearchParams, - url: string | URL, - metadata?: AuthorizationServerMetadata -) => void | Promise; - -/** - * 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. - */ -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 a 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 - * `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'): 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 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; -} - -export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; - -export class UnauthorizedError extends Error { - constructor(message?: string) { - super(message ?? 'Unauthorized'); - } -} - -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; - - // If server doesn't specify supported methods, use RFC 6749 defaults - if (supportedMethods.length === 0) { - return hasClientSecret ? 'client_secret_post' : 'none'; - } - - // Prefer the method returned by the server during client registration if valid and supported - if ( - 'token_endpoint_auth_method' in clientInformation && - clientInformation.token_endpoint_auth_method && - isClientAuthMethod(clientInformation.token_endpoint_auth_method) && - supportedMethods.includes(clientInformation.token_endpoint_auth_method) - ) { - return clientInformation.token_endpoint_auth_method; - } - - // 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 - */ -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) - */ -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) - */ -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) - */ -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 instance of the appropriate OAuthError subclass will be returned. - * If parsing fails, it falls back to a generic 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 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)); - const { error, error_description, error_uri } = result; - const errorClass = OAUTH_ERRORS[error] || ServerError; - return new errorClass(error_description || '', error_uri); - } 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 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 InvalidClientError || error instanceof UnauthorizedClientError) { - await provider.invalidateCredentials?.('all'); - return await authInternal(provider, options); - } else if (error instanceof InvalidGrantError) { - await provider.invalidateCredentials?.('tokens'); - return await authInternal(provider, options); - } - - // Throw otherwise - throw error; - } -} - -async function authInternal( - provider: OAuthClientProvider, - { - serverUrl, - authorizationCode, - scope, - resourceMetadataUrl, - fetchFn - }: { - serverUrl: string | URL; - authorizationCode?: string; - scope?: string; - resourceMetadataUrl?: URL; - fetchFn?: FetchLike; - } -): Promise { - let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - let authorizationServerUrl: string | URL | undefined; - - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { - authorizationServerUrl = resourceMetadata.authorization_servers[0]; - } - } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server - } - - /** - * If we don't get a valid authorization server metadata from protected resource metadata, - * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server base URL acts as the Authorization server. - */ - if (!authorizationServerUrl) { - authorizationServerUrl = new URL('/', serverUrl); - } - - const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); - - const metadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { - fetchFn - }); - - // 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 InvalidClientMetadataError( - `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, - 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, - 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 instanceof 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: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope, - resource - }); - - await provider.saveCodeVerifier(codeVerifier); - await provider.redirectToAuthorization(authorizationUrl); - return 'REDIRECT'; -} - -/** - * 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(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`); - const match = wwwAuthHeader.match(pattern); - - if (match) { - // Pattern matches: field_name="value" or field_name=value (unquoted) - return match[1] || match[2]; - } - - return null; -} - -/** - * Extract resource_metadata from response header. - * @deprecated Use `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) { - return undefined; - } - - try { - return new URL(match[1]); - } catch { - return undefined; - } -} - -/** - * Looks up 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?.body?.cancel(); - throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); - } - - if (!response.ok) { - await response.body?.cancel(); - throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`); - } - return OAuthProtectedResourceMetadataSchema.parse(await response.json()); -} - -/** - * Helper function to handle fetch with CORS retry logic - */ -async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { - try { - return await fetchFn(url, { headers }); - } catch (error) { - if (error instanceof TypeError) { - if (headers) { - // CORS errors come back as TypeError, retry without headers - return fetchWithCorsRetry(url, undefined, fetchFn); - } else { - // We're getting CORS errors on retry too, return undefined - return undefined; - } - } - throw error; - } -} - -/** - * 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 { - return !response || (response.status >= 400 && response.status < 500 && pathname !== '/'); -} - -/** - * 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 with 404 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 `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?.body?.cancel(); - return undefined; - } - - if (!response.ok) { - await response.body?.cancel(); - 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) { - // Root path: https://example.com/.well-known/oauth-authorization-server - urlsToTry.push({ - url: new URL('/.well-known/oauth-authorization-server', url.origin), - type: 'oauth' - }); - - // OIDC: https://example.com/.well-known/openid-configuration - urlsToTry.push({ - 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); - } - - // 1. OAuth metadata at the given URL - // Insert well-known before the path: https://example.com/.well-known/oauth-authorization-server/tenant1 - urlsToTry.push({ - 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 - urlsToTry.push({ - 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 - urlsToTry.push({ - url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin), - type: 'oidc' - }); - - return urlsToTry; -} - -/** - * Discovers authorization server metadata with support for RFC 8414 OAuth 2.0 Authorization Server Metadata - * and 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 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.body?.cancel(); - // Continue looking for any 4xx response code. - if (response.status >= 400 && response.status < 500) { - continue; // Try next URL - } - throw new Error( - `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}` - ); - } - - // Parse and validate based on type - if (type === 'oauth') { - return OAuthMetadataSchema.parse(await response.json()); - } else { - return OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); - } - } - - return undefined; -} - -/** - * 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?.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 fetchToken when the provider - * doesn't implement 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 exchangeAuthorization, refreshAuthorization, and fetchToken. - */ -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); - } - - return OAuthTokensSchema.parse(await response.json()); -} - -/** - * 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 provider.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 - * // Provider for client_credentials: - * class MyProvider implements OAuthClientProvider { - * prepareTokenRequest(scope) { - * const params = new URLSearchParams({ grant_type: 'client_credentials' }); - * if (scope) params.set('scope', scope); - * return params; - * } - * // ... other methods - * } - * - * const tokens = await fetchToken(provider, authServerUrl, { metadata }); - */ -export async function fetchToken( - provider: OAuthClientProvider, - authorizationServerUrl: string | URL, - { - metadata, - resource, - authorizationCode, - fetchFn - }: { - metadata?: AuthorizationServerMetadata; - resource?: URL; - /** Authorization code for the default authorization_code grant flow */ - authorizationCode?: string; - fetchFn?: FetchLike; - } = {} -): Promise { - const 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(scope); - } - - // 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 RFC 7591. - */ -export async function registerClient( - authorizationServerUrl: string | URL, - { - metadata, - clientMetadata, - fetchFn - }: { - metadata?: AuthorizationServerMetadata; - clientMetadata: OAuthClientMetadata; - 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) - }); - - if (!response.ok) { - throw await parseErrorResponse(response); - } - - return OAuthClientInformationFullSchema.parse(await response.json()); -} diff --git a/src/client/index.ts b/src/client/index.ts deleted file mode 100644 index 28c0e6253..000000000 --- a/src/client/index.ts +++ /dev/null @@ -1,905 +0,0 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; -import type { Transport } from '../shared/transport.js'; - -import { - type CallToolRequest, - CallToolResultSchema, - type ClientCapabilities, - type ClientNotification, - type ClientRequest, - type ClientResult, - type CompatibilityCallToolResultSchema, - type CompleteRequest, - CompleteResultSchema, - EmptyResultSchema, - ErrorCode, - type GetPromptRequest, - GetPromptResultSchema, - type Implementation, - InitializeResultSchema, - LATEST_PROTOCOL_VERSION, - type ListPromptsRequest, - ListPromptsResultSchema, - type ListResourcesRequest, - ListResourcesResultSchema, - type ListResourceTemplatesRequest, - ListResourceTemplatesResultSchema, - type ListToolsRequest, - ListToolsResultSchema, - type LoggingLevel, - McpError, - type ReadResourceRequest, - ReadResourceResultSchema, - type ServerCapabilities, - SUPPORTED_PROTOCOL_VERSIONS, - type SubscribeRequest, - type Tool, - type UnsubscribeRequest, - ElicitResultSchema, - ElicitRequestSchema, - CreateTaskResultSchema, - CreateMessageRequestSchema, - CreateMessageResultSchema, - ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema, - ResourceListChangedNotificationSchema, - ListChangedOptions, - ListChangedOptionsBaseSchema, - type ListChangedHandlers, - type Request, - type Notification, - type Result -} from '../types.js'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; -import { - AnyObjectSchema, - SchemaOutput, - getObjectShape, - isZ4Schema, - safeParse, - type ZodV3Internal, - type ZodV4Internal -} from '../server/zod-compat.js'; -import type { RequestHandlerExtra } from '../shared/protocol.js'; -import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.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 }; -} - -export type ClientOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this client. - */ - capabilities?: ClientCapabilities; - - /** - * JSON Schema validator for tool output validation. - * - * The validator is used to validate structured content returned by tools - * against their declared output schemas. - * - * @default AjvJsonSchemaValidator - * - * @example - * ```typescript - * // ajv - * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new AjvJsonSchemaValidator() - * } - * ); - * - * // @cfworker/json-schema - * const client = new Client( - * { name: 'my-client', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() - * } - * ); - * ``` - */ - jsonSchemaValidator?: jsonSchemaValidator; - - /** - * Configure handlers for list changed notifications (tools, prompts, resources). - * - * @example - * ```typescript - * 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 connect() is called. - * - * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: - * - * ```typescript - * // Custom schemas - * const CustomRequestSchema = RequestSchema.extend({...}) - * const CustomNotificationSchema = NotificationSchema.extend({...}) - * const CustomResultSchema = ResultSchema.extend({...}) - * - * // Type aliases - * type CustomRequest = z.infer - * type CustomNotification = z.infer - * type CustomResult = z.infer - * - * // Create typed client - * const client = new Client({ - * name: "CustomClient", - * version: "1.0.0" - * }) - * ``` - */ -export class Client< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> extends Protocol { - private _serverCapabilities?: ServerCapabilities; - private _serverVersion?: Implementation; - 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; - - /** - * Initializes this client with the given name and version information. - */ - constructor( - private _clientInfo: Implementation, - options?: ClientOptions - ) { - super(options); - this._capabilities = options?.capabilities ?? {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); - - // Store list changed config for setup after connection (when we know server capabilities) - if (options?.listChanged) { - this._pendingListChangedConfig = options.listChanged; - } - } - - /** - * 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', ToolListChangedNotificationSchema, config.tools, async () => { - const result = await this.listTools(); - return result.tools; - }); - } - - if (config.prompts && this._serverCapabilities?.prompts?.listChanged) { - this._setupListChangedHandler('prompts', PromptListChangedNotificationSchema, config.prompts, async () => { - const result = await this.listPrompts(); - return result.prompts; - }); - } - - if (config.resources && this._serverCapabilities?.resources?.listChanged) { - this._setupListChangedHandler('resources', ResourceListChangedNotificationSchema, 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); - } - - /** - * Override request handler registration to enforce client-side validation for elicitation. - */ - public override setRequestHandler( - requestSchema: T, - handler: ( - request: SchemaOutput, - extra: RequestHandlerExtra - ) => ClientResult | ResultT | Promise - ): void { - const shape = getObjectShape(requestSchema); - const methodSchema = shape?.method; - if (!methodSchema) { - throw new Error('Schema is missing a method literal'); - } - - // Extract literal value using type-safe property access - let methodValue: unknown; - if (isZ4Schema(methodSchema)) { - const v4Schema = methodSchema as unknown as ZodV4Internal; - const v4Def = v4Schema._zod?.def; - methodValue = v4Def?.value ?? v4Schema.value; - } else { - const v3Schema = methodSchema as unknown as ZodV3Internal; - const legacyDef = v3Schema._def; - methodValue = legacyDef?.value ?? v3Schema.value; - } - - if (typeof methodValue !== 'string') { - throw new Error('Schema method literal must be a string'); - } - const method = methodValue; - if (method === 'elicitation/create') { - const wrappedHandler = async ( - request: SchemaOutput, - extra: RequestHandlerExtra - ): Promise => { - const validatedRequest = safeParse(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 McpError(ErrorCode.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 McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); - } - - if (params.mode === 'url' && !supportsUrlMode) { - throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); - } - - const result = await Promise.resolve(handler(request, extra)); - - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = safeParse(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against ElicitResultSchema - const validationResult = safeParse(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 McpError(ErrorCode.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) { - if (this._capabilities.elicitation?.form?.applyDefaults) { - try { - applyElicitationDefaults(requestedSchema, validatedResult.content); - } catch { - // gracefully ignore errors in default application - } - } - } - - return validatedResult; - }; - - // Install the wrapped handler - return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); - } - - if (method === 'sampling/createMessage') { - const wrappedHandler = async ( - request: SchemaOutput, - extra: RequestHandlerExtra - ): Promise => { - const validatedRequest = safeParse(CreateMessageRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid sampling request: ${errorMessage}`); - } - - const { params } = validatedRequest.data; - - const result = await Promise.resolve(handler(request, extra)); - - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = safeParse(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against CreateMessageResultSchema - const validationResult = safeParse(CreateMessageResultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid sampling result: ${errorMessage}`); - } - - return validationResult.data; - }; - - // Install the wrapped handler - return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); - } - - // Other handlers use default behavior - return super.setRequestHandler(requestSchema, handler); - } - - protected assertCapability(capability: keyof ServerCapabilities, method: string): void { - if (!this._serverCapabilities?.[capability]) { - throw new Error(`Server does not support ${capability} (required for ${method})`); - } - } - - 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. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { - const result = await this.request( - { - method: 'initialize', - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: this._capabilities, - clientInfo: this._clientInfo - } - }, - InitializeResultSchema, - options - ); - - if (result === undefined) { - throw new Error(`Server sent invalid initialize result: ${result}`); - } - - if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { - throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); - } - - this._serverCapabilities = result.capabilities; - this._serverVersion = result.serverInfo; - // 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 may be populated with information about the server's instructions. - */ - getInstructions(): string | undefined { - return this._instructions; - } - - protected assertCapabilityForMethod(method: RequestT['method']): void { - switch (method as ClientRequest['method']) { - case 'logging/setLevel': - if (!this._serverCapabilities?.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'prompts/get': - case 'prompts/list': - if (!this._serverCapabilities?.prompts) { - throw new Error(`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 Error(`Server does not support resources (required for ${method})`); - } - - if (method === 'resources/subscribe' && !this._serverCapabilities.resources.subscribe) { - throw new Error(`Server does not support resource subscriptions (required for ${method})`); - } - - break; - - case 'tools/call': - case 'tools/list': - if (!this._serverCapabilities?.tools) { - throw new Error(`Server does not support tools (required for ${method})`); - } - break; - - case 'completion/complete': - if (!this._serverCapabilities?.completions) { - throw new Error(`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: NotificationT['method']): void { - switch (method as ClientNotification['method']) { - case 'notifications/roots/list_changed': - if (!this._capabilities.roots?.listChanged) { - throw new Error(`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 { - // Task handlers are registered in Protocol constructor before _capabilities is initialized - // Skip capability check for task methods during initialization - if (!this._capabilities) { - return; - } - - switch (method) { - case 'sampling/createMessage': - if (!this._capabilities.sampling) { - throw new Error(`Client does not support sampling capability (required for ${method})`); - } - break; - - case 'elicitation/create': - if (!this._capabilities.elicitation) { - throw new Error(`Client does not support elicitation capability (required for ${method})`); - } - break; - - case 'roots/list': - if (!this._capabilities.roots) { - throw new Error(`Client does not support roots capability (required for ${method})`); - } - break; - - case 'tasks/get': - case 'tasks/list': - case 'tasks/result': - case 'tasks/cancel': - if (!this._capabilities.tasks) { - throw new Error(`Client does not support tasks 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 { - // Task handlers are registered in Protocol constructor before _capabilities is initialized - // Skip capability check for task methods during initialization - if (!this._capabilities) { - return; - } - - assertClientRequestTaskCapability(this._capabilities.tasks?.requests, method, 'Client'); - } - - async ping(options?: RequestOptions) { - return this.request({ method: 'ping' }, EmptyResultSchema, options); - } - - async complete(params: CompleteRequest['params'], options?: RequestOptions) { - return this.request({ method: 'completion/complete', params }, CompleteResultSchema, options); - } - - async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { - return this.request({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); - } - - async getPrompt(params: GetPromptRequest['params'], options?: RequestOptions) { - return this.request({ method: 'prompts/get', params }, GetPromptResultSchema, options); - } - - async listPrompts(params?: ListPromptsRequest['params'], options?: RequestOptions) { - return this.request({ method: 'prompts/list', params }, ListPromptsResultSchema, options); - } - - async listResources(params?: ListResourcesRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/list', params }, ListResourcesResultSchema, options); - } - - async listResourceTemplates(params?: ListResourceTemplatesRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/templates/list', params }, ListResourceTemplatesResultSchema, options); - } - - async readResource(params: ReadResourceRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/read', params }, ReadResourceResultSchema, options); - } - - async subscribeResource(params: SubscribeRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/subscribe', params }, EmptyResultSchema, options); - } - - async unsubscribeResource(params: UnsubscribeRequest['params'], options?: RequestOptions) { - return this.request({ method: 'resources/unsubscribe', params }, EmptyResultSchema, options); - } - - /** - * Calls a tool and waits for the result. Automatically validates structured output if the tool has an outputSchema. - * - * For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead. - */ - async callTool( - params: CallToolRequest['params'], - resultSchema: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema = CallToolResultSchema, - options?: RequestOptions - ) { - // Guard: required-task tools need experimental API - if (this.isToolTaskRequired(params.name)) { - throw new McpError( - ErrorCode.InvalidRequest, - `Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.` - ); - } - - const result = await this.request({ method: 'tools/call', params }, resultSchema, 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 McpError( - ErrorCode.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 McpError( - ErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` - ); - } - } catch (error) { - if (error instanceof McpError) { - throw error; - } - throw new McpError( - ErrorCode.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 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 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); - } - - async listTools(params?: ListToolsRequest['params'], options?: RequestOptions) { - const result = await this.request({ 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, - notificationSchema: { shape: { method: { value: string } } }, - options: ListChangedOptions, - fetcher: () => Promise - ): void { - // Validate options using Zod schema (validates autoRefresh and debounceMs) - const parseResult = ListChangedOptionsBaseSchema.safeParse(options); - if (!parseResult.success) { - throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); - } - - // Validate callback - if (typeof options.onChanged !== 'function') { - throw new Error(`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 (e) { - const error = e instanceof Error ? e : new Error(String(e)); - onChanged(error, 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(notificationSchema as AnyObjectSchema, handler); - } - - async sendRootsListChanged() { - return this.notification({ method: 'notifications/roots/list_changed' }); - } -} diff --git a/src/client/middleware.ts b/src/client/middleware.ts deleted file mode 100644 index c8f7fdd3d..000000000 --- a/src/client/middleware.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { FetchLike } from '../shared/transport.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 (InvalidClientError, 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 = Array.from(requestHeaders.entries()) - .map(([key, value]) => `${key}: ${value}`) - .join(', '); - message += `\n Request Headers: {${reqHeaders}}`; - } - - if (includeResponseHeaders && responseHeaders) { - const resHeaders = Array.from(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 - * ```typescript - * // 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 => { - return middleware.reduce((handler, mw) => mw(handler), next); - }; -}; - -/** - * Helper function to create custom fetch middleware with cleaner syntax. - * Provides the next handler and request details as separate parameters for easier access. - * - * @example - * ```typescript - * // 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/src/client/sse.ts b/src/client/sse.ts deleted file mode 100644 index f0e91ff25..000000000 --- a/src/client/sse.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; -import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, 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 `SSEClientTransport`. - */ -export type SSEClientTransportOptions = { - /** - * An OAuth client provider to use for authentication. - * - * When an `authProvider` is specified and the SSE connection is started: - * 1. The connection is attempted with any existing access token from the `authProvider`. - * 2. If the access token has expired, the `authProvider` is used to refresh the token. - * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * - * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `SSEClientTransport.finishAuth` with the authorization code before retrying the connection. - * - * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * - * `UnauthorizedError` might also be thrown when sending any message over the SSE transport, indicating that the session has expired, and needs to be re-authed and reconnected. - */ - 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 `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 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?: 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; - this._authProvider = opts?.authProvider; - this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); - } - - private async _authThenStart(): Promise { - if (!this._authProvider) { - throw new UnauthorizedError('No auth provider'); - } - - let result: AuthResult; - try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - return await this._startOrAuth(); - } - - private async _commonHeaders(): Promise { - const headers: HeadersInit & Record = {}; - if (this._authProvider) { - const tokens = await this._authProvider.tokens(); - if (tokens) { - headers['Authorization'] = `Bearer ${tokens.access_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 && 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) { - this._authThenStart().then(resolve, reject); - 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._authProvider) { - throw new UnauthorizedError('No auth provider'); - } - - const result = await auth(this._authProvider, { - 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 { - if (!this._endpoint) { - throw new Error('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) { - const text = await response.text().catch(() => null); - - if (response.status === 401 && this._authProvider) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; - - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - // Purposely _not_ awaited, so we don't call onerror twice - return this.send(message); - } - - throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); - } - - // Release connection - POST responses don't have content we need - await response.body?.cancel(); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - } - - setProtocolVersion(version: string): void { - this._protocolVersion = version; - } -} diff --git a/src/client/stdio.ts b/src/client/stdio.ts deleted file mode 100644 index e488dcd24..000000000 --- a/src/client/stdio.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { ChildProcess, IOType } from 'node:child_process'; -import spawn from 'cross-spawn'; -import process from 'node:process'; -import { Stream, PassThrough } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage } from '../types.js'; - -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 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' && isElectron(), - 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 `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, 2_000).unref())]); - - if (processToClose.exitCode === null) { - try { - processToClose.kill('SIGTERM'); - } catch { - // ignore - } - - await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).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 Error('Not connected'); - } - - const json = serializeMessage(message); - if (this._process.stdin.write(json)) { - resolve(); - } else { - this._process.stdin.once('drain', resolve); - } - }); - } -} - -function isElectron() { - return 'type' in process; -} diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts deleted file mode 100644 index 736587973..000000000 --- a/src/client/streamableHttp.ts +++ /dev/null @@ -1,674 +0,0 @@ -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; -import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { EventSourceParserStream } from 'eventsource-parser/stream'; - -// Default reconnection options for StreamableHTTP connections -const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { - initialReconnectionDelay: 1000, - maxReconnectionDelay: 30000, - reconnectionDelayGrowFactor: 1.5, - maxRetries: 2 -}; - -export class StreamableHTTPError extends Error { - constructor( - public readonly code: number | undefined, - message: string | undefined - ) { - super(`Streamable HTTP error: ${message}`); - } -} - -/** - * 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 response can be associate with the new resumed request. - */ - replayMessageId?: string | number; -} - -/** - * Configuration options for reconnection behavior of the 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; -} - -/** - * Configuration options for the `StreamableHTTPClientTransport`. - */ -export type StreamableHTTPClientTransportOptions = { - /** - * An OAuth client provider to use for authentication. - * - * When an `authProvider` is specified and the connection is started: - * 1. The connection is attempted with any existing access token from the `authProvider`. - * 2. If the access token has expired, the `authProvider` is used to refresh the token. - * 3. If token refresh fails or no access token exists, and auth is required, `OAuthClientProvider.redirectToAuthorization` is called, and an `UnauthorizedError` will be thrown from `connect`/`start`. - * - * After the user has finished authorizing via their user agent, and is redirected back to the MCP client application, call `StreamableHTTPClientTransport.finishAuth` with the authorization code before retrying the connection. - * - * If an `authProvider` is not provided, and auth is required, an `UnauthorizedError` will be thrown. - * - * `UnauthorizedError` might also be thrown when sending any message over the transport, indicating that the session has expired, and needs to be re-authed and reconnected. - */ - 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; - - /** - * 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; -}; - -/** - * 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?: OAuthClientProvider; - private _fetch?: FetchLike; - private _fetchWithInit: FetchLike; - private _sessionId?: string; - private _reconnectionOptions: StreamableHTTPReconnectionOptions; - private _protocolVersion?: string; - private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 - private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. - private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field - private _reconnectionTimeout?: ReturnType; - - 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; - this._authProvider = opts?.authProvider; - this._fetch = opts?.fetch; - this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); - this._sessionId = opts?.sessionId; - this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; - } - - private async _authThenStart(): Promise { - if (!this._authProvider) { - throw new UnauthorizedError('No auth provider'); - } - - let result: AuthResult; - try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - return await this._startOrAuthSse({ resumptionToken: undefined }); - } - - private async _commonHeaders(): Promise { - const headers: HeadersInit & Record = {}; - if (this._authProvider) { - const tokens = await this._authProvider.tokens(); - if (tokens) { - headers['Authorization'] = `Bearer ${tokens.access_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): 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(); - headers.set('Accept', 'text/event-stream'); - - // 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, { - method: 'GET', - headers, - signal: this._abortController?.signal - }); - - if (!response.ok) { - await response.body?.cancel(); - - if (response.status === 401 && this._authProvider) { - // Need to authenticate - return await this._authThenStart(); - } - - // 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 StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`); - } - - this._handleSseStream(response.body, options, true); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - } - - /** - * Calculates the next reconnection delay using 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); - - // Schedule the reconnection - this._reconnectionTimeout = setTimeout(() => { - // Use the last event ID to resume where we left off - this._startOrAuthSse(options).catch(error => { - this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); - // Schedule another attempt if this one failed, incrementing the attempt counter - this._scheduleReconnection(options, attemptCount + 1); - }); - }, delay); - } - - 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)); - if (isJSONRPCResultResponse(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._authProvider) { - throw new UnauthorizedError('No auth provider'); - } - - const result = await auth(this._authProvider, { - 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 { - if (this._reconnectionTimeout) { - clearTimeout(this._reconnectionTimeout); - this._reconnectionTimeout = undefined; - } - this._abortController?.abort(); - this.onclose?.(); - } - - async send( - message: JSONRPCMessage | JSONRPCMessage[], - options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } - ): Promise { - try { - const { resumptionToken, onresumptiontoken } = options || {}; - - if (resumptionToken) { - // If we have at last event ID, we need to reconnect the SSE stream - this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(err => - this.onerror?.(err) - ); - return; - } - - const headers = await this._commonHeaders(); - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - - 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) { - const text = await response.text().catch(() => null); - - if (response.status === 401 && this._authProvider) { - // Prevent infinite recursion when server returns 401 after successful auth - if (this._hasCompletedAuthFlow) { - throw new StreamableHTTPError(401, 'Server returned 401 after successful authentication'); - } - - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); - this._resourceMetadataUrl = resourceMetadataUrl; - this._scope = scope; - - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetchWithInit - }); - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - // Mark that we completed auth flow - this._hasCompletedAuthFlow = true; - // Purposely _not_ awaited, so we don't call onerror twice - return this.send(message); - } - - if (response.status === 403 && this._authProvider) { - 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 StreamableHTTPError(403, 'Server returned 403 after trying upscoping'); - } - - if (scope) { - this._scope = scope; - } - - if (resourceMetadataUrl) { - this._resourceMetadataUrl = resourceMetadataUrl; - } - - // Mark that upscoping was tried. - this._lastUpscopingHeader = wwwAuthHeader ?? undefined; - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - scope: this._scope, - fetchFn: this._fetch - }); - - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } - - return this.send(message); - } - } - - throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); - } - - // Reset auth loop flag on successful response - this._hasCompletedAuthFlow = false; - this._lastUpscopingHeader = undefined; - - // If the response is 202 Accepted, there's no body to process - if (response.status === 202) { - await response.body?.cancel(); - // 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(err => this.onerror?.(err)); - } - return; - } - - // Get original message(s) for detecting request IDs - const messages = Array.isArray(message) ? message : [message]; - - const hasRequests = messages.filter(msg => 'method' in msg && 'id' in msg && msg.id !== undefined).length > 0; - - // 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.body?.cancel(); - throw new StreamableHTTPError(-1, `Unexpected content type: ${contentType}`); - } - } else { - // No requests in message but got 200 OK - still need to release connection - await response.body?.cancel(); - } - } 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.body?.cancel(); - - // 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 StreamableHTTPError(response.status, `Failed to terminate session: ${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/src/client/websocket.ts b/src/client/websocket.ts deleted file mode 100644 index aed766caf..000000000 --- a/src/client/websocket.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; - -const SUBPROTOCOL = 'mcp'; - -/** - * Client transport for WebSocket: this will connect to a server over the WebSocket protocol. - */ -export class WebSocketClientTransport implements Transport { - private _socket?: WebSocket; - private _url: URL; - - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage) => void; - - constructor(url: URL) { - this._url = url; - } - - start(): Promise { - if (this._socket) { - throw new Error( - 'WebSocketClientTransport already started! If using Client class, note that connect() calls start() automatically.' - ); - } - - return new Promise((resolve, reject) => { - this._socket = new WebSocket(this._url, SUBPROTOCOL); - - this._socket.onerror = event => { - const error = 'error' in event ? (event.error as Error) : new Error(`WebSocket error: ${JSON.stringify(event)}`); - reject(error); - this.onerror?.(error); - }; - - this._socket.onopen = () => { - resolve(); - }; - - this._socket.onclose = () => { - this.onclose?.(); - }; - - this._socket.onmessage = (event: MessageEvent) => { - let message: JSONRPCMessage; - try { - message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); - } catch (error) { - this.onerror?.(error as Error); - return; - } - - this.onmessage?.(message); - }; - }); - } - - async close(): Promise { - this._socket?.close(); - } - - send(message: JSONRPCMessage): Promise { - return new Promise((resolve, reject) => { - if (!this._socket) { - reject(new Error('Not connected')); - return; - } - - this._socket?.send(JSON.stringify(message)); - resolve(); - }); - } -} diff --git a/src/examples/README.md b/src/examples/README.md deleted file mode 100644 index dd67bc8f8..000000000 --- a/src/examples/README.md +++ /dev/null @@ -1,352 +0,0 @@ -# MCP TypeScript SDK Examples - -This directory contains example implementations of MCP clients and servers using the TypeScript SDK. For a high-level index of scenarios and where they live, see the **Examples** table in the root `README.md`. - -## Table of Contents - -- [Client Implementations](#client-implementations) - - [Streamable HTTP Client](#streamable-http-client) - - [Backwards Compatible Client](#backwards-compatible-client) - - [URL Elicitation Example Client](#url-elicitation-example-client) -- [Server Implementations](#server-implementations) - - [Single Node Deployment](#single-node-deployment) - - [Streamable HTTP Transport](#streamable-http-transport) - - [Deprecated SSE Transport](#deprecated-sse-transport) - - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) - - [Form Elicitation Example](#form-elicitation-example) - - [URL Elicitation Example](#url-elicitation-example) - - [Multi-Node Deployment](#multi-node-deployment) -- [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) - -## Client Implementations - -### Streamable HTTP Client - -A full-featured interactive client that connects to a Streamable HTTP server, demonstrating how to: - -- Establish and manage a connection to an MCP server -- List and call tools with arguments -- Handle notifications through the SSE stream -- List and get prompts with arguments -- List available resources -- Handle session termination and reconnection -- Support for resumability with Last-Event-ID tracking - -```bash -npx tsx src/examples/client/simpleStreamableHttp.ts -``` - -Example client with OAuth: - -```bash -npx tsx src/examples/client/simpleOAuthClient.ts -``` - -Client credentials (machine-to-machine) example: - -```bash -npx tsx src/examples/client/simpleClientCredentials.ts -``` - -### Backwards Compatible Client - -A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: - -- The client first POSTs an initialize request to the server URL: - - If successful, it uses the Streamable HTTP transport - - If it fails with a 4xx status, it attempts a GET request to establish an SSE stream - -```bash -npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts -``` - -### URL Elicitation Example Client - -A client that demonstrates how to use URL elicitation to securely collect _sensitive_ user input or perform secure third-party flows. - -```bash -# First, run the server: -npx tsx src/examples/server/elicitationUrlExample.ts - -# Then, run the client: -npx tsx src/examples/client/elicitationUrlExample.ts - -``` - -## Server Implementations - -### Single Node Deployment - -These examples demonstrate how to set up an MCP server on a single node with different transport options. - -#### Streamable HTTP Transport - -##### Simple Streamable HTTP Server - -A server that implements the Streamable HTTP transport (protocol version 2025-03-26). - -- Basic server setup with Express and the Streamable HTTP transport -- Session management with an in-memory event store for resumability -- Tool implementation with the `greet` and `multi-greet` tools -- Prompt implementation with the `greeting-template` prompt -- Static resource exposure -- Support for notifications via SSE stream established by GET requests -- Session termination via DELETE requests - -```bash -npx tsx src/examples/server/simpleStreamableHttp.ts - -# To add a demo of authentication to this example, use: -npx tsx src/examples/server/simpleStreamableHttp.ts --oauth - -# To mitigate impersonation risks, enable strict Resource Identifier verification: -npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict -``` - -##### JSON Response Mode Server - -A server that uses Streamable HTTP transport with JSON response mode enabled (no SSE). - -- Streamable HTTP with JSON response mode, which returns responses directly in the response body -- Limited support for notifications (since SSE is disabled) -- Proper response handling according to the MCP specification for servers that don't support SSE -- Returning appropriate HTTP status codes for unsupported methods - -```bash -npx tsx src/examples/server/jsonResponseStreamableHttp.ts -``` - -##### Streamable HTTP with server notifications - -A server that demonstrates server notifications using Streamable HTTP. - -- Resource list change notifications with dynamically added resources -- Automatic resource creation on a timed interval - -```bash -npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts -``` - -##### Form Elicitation Example - -A server that demonstrates using form elicitation to collect _non-sensitive_ user input. - -```bash -npx tsx src/examples/server/elicitationFormExample.ts -``` - -##### URL Elicitation Example - -A comprehensive example demonstrating URL mode elicitation in a server protected by MCP authorization. This example shows: - -- SSE-driven URL elicitation of an API Key on session initialization: obtain sensitive user input at session init -- Tools that require direct user interaction via URL elicitation (for payment confirmation and for third-party OAuth tokens) -- Completion notifications for URL elicitation - -To run this example: - -```bash -# Start the server -npx tsx src/examples/server/elicitationUrlExample.ts - -# In a separate terminal, start the client -npx tsx src/examples/client/elicitationUrlExample.ts -``` - -#### Deprecated SSE Transport - -A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example is only used for testing backwards compatibility for clients. - -- Two separate endpoints: `/mcp` for the SSE stream (GET) and `/messages` for client messages (POST) -- Tool implementation with a `start-notification-stream` tool that demonstrates sending periodic notifications - -```bash -npx tsx src/examples/server/simpleSseServer.ts -``` - -#### Streamable Http Backwards Compatible Server with SSE - -A server that supports both Streamable HTTP and SSE transports, adhering to the [MCP specification for backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility). - -- Single MCP server instance with multiple transport options -- Support for Streamable HTTP requests at `/mcp` endpoint (GET/POST/DELETE) -- Support for deprecated SSE transport with `/sse` (GET) and `/messages` (POST) -- Session type tracking to avoid mixing transport types -- Notifications and tool execution across both transport types - -```bash -npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts -``` - -### Multi-Node Deployment - -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 to MCP servers. Useful for simple API wrapper servers. -- **Persistent storage mode** - No local state needed, but session data is stored in a database. Example: an MCP server for online ordering where the shopping cart is stored in a database. -- **Local state with message routing** - Local state is needed, and all requests for a session must be routed to the correct node. This can be done with a message queue and pub/sub system. - -#### Stateless Mode - -The Streamable HTTP transport can be configured to operate without tracking sessions. This is perfect for simple API proxies or when each request is completely independent. - -##### Implementation - -To enable stateless mode, configure the `StreamableHTTPServerTransport` with: - -```typescript -sessionIdGenerator: undefined; -``` - -This disables session management entirely, and the server won't generate or expect session IDs. - -- No session ID headers are sent or expected -- Any server node can process any request -- No state is preserved between requests -- Perfect for RESTful or stateless API scenarios -- Simplest deployment model with minimal infrastructure requirements - -``` -┌─────────────────────────────────────────────┐ -│ Client │ -└─────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────┐ -│ Load Balancer │ -└─────────────────────────────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────┐ ┌─────────────────────┐ -│ MCP Server #1 │ │ MCP Server #2 │ -│ (Node.js) │ │ (Node.js) │ -└─────────────────┘ └─────────────────────┘ -``` - -#### Persistent Storage Mode - -For cases where you need session continuity but don't need to maintain in-memory state on specific nodes, you can use a database to persist session data while still allowing any node to handle requests. - -##### Implementation - -Configure the transport with session management, but retrieve and store all state in an external persistent storage: - -```typescript -sessionIdGenerator: () => randomUUID(), -eventStore: databaseEventStore -``` - -All session state is stored in the database, and any node can serve any client by retrieving the state when needed. - -- Maintains sessions with unique IDs -- Stores all session data in an external database -- Provides resumability through the database-backed EventStore -- Any node can handle any request for the same session -- No node-specific memory state means no need for message routing -- Good for applications where state can be fully externalized -- Somewhat higher latency due to database access for each request - -``` -┌─────────────────────────────────────────────┐ -│ 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 (such as Computer Use or complex session state), the Streamable HTTP transport can be combined with a pub/sub system to route messages to the correct node handling each session. - -1. **Bidirectional Message Queue Integration**: - - All nodes both publish to and subscribe from the message queue - - Each node registers the sessions it's actively handling - - Messages are routed based on session ownership - -2. **Request Handling Flow**: - - When a client connects to Node A with an existing `mcp-session-id` - - If Node A doesn't own this session, it: - - Establishes and maintains the SSE connection with the client - - Publishes the request to the message queue with the session ID - - Node B (which owns the session) receives the request from the queue - - Node B processes the request with its local session state - - Node B publishes responses/notifications back to the queue - - Node A subscribes to the response channel and forwards to the client - -3. **Channel Identification**: - - Each message channel combines both `mcp-session-id` and `stream-id` - - This ensures responses are correctly routed back to the originating connection - -``` -┌─────────────────────────────────────────────┐ -│ 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 │ -└─────────────────────────────────────────────┘ -``` - -- Maintains session affinity for stateful operations without client redirection -- Enables horizontal scaling while preserving complex in-memory state -- Provides fault tolerance through the message queue as intermediary - -## Backwards Compatibility - -### Testing Streamable HTTP Backwards Compatibility with SSE - -To test the backwards compatibility features: - -1. Start one of the server implementations: - - ```bash - # Legacy SSE server (protocol version 2024-11-05) - npx tsx src/examples/server/simpleSseServer.ts - - # Streamable HTTP server (protocol version 2025-03-26) - npx tsx src/examples/server/simpleStreamableHttp.ts - - # Backwards compatible server (supports both protocols) - npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts - ``` - -2. Then run the backwards compatible client: - ```bash - npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts - ``` - -This demonstrates how the MCP ecosystem ensures interoperability between clients and servers regardless of which protocol version they were built for. diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts deleted file mode 100644 index b57927e3f..000000000 --- a/src/examples/client/elicitationUrlExample.ts +++ /dev/null @@ -1,791 +0,0 @@ -// Run with: npx tsx src/examples/client/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 { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { createInterface } from 'node:readline'; -import { - ListToolsRequest, - ListToolsResultSchema, - CallToolRequest, - CallToolResultSchema, - ElicitRequestSchema, - ElicitRequest, - ElicitResult, - ResourceLink, - ElicitRequestURLParams, - McpError, - ErrorCode, - UrlElicitationRequiredError, - ElicitationCompleteNotificationSchema -} from '../../types.js'; -import { getDisplayName } from '../../shared/metadataUtils.js'; -import { OAuthClientMetadata } from '../../shared/auth.js'; -import { exec } from 'node:child_process'; -import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -import { UnauthorizedError } from '../../client/auth.js'; -import { createServer } from 'node:http'; - -// 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`; -let oauthProvider: InMemoryOAuthClientProvider | undefined = undefined; - -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' -}; -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 = 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); - 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) { - resolve(); - } else { - elicitationsCompleteSignal = 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; - } - } -} - -async function openBrowser(url: string): Promise { - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - 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 McpError(ErrorCode.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⚠️ \x1b[33mSECURITY WARNING\x1b[0m ⚠️'); - console.log('\x1b[33mThe server is requesting you to open an external URL.\x1b[0m'); - console.log('\x1b[33mOnly proceed if you trust this server and understand why it needs this.\x1b[0m\n'); - console.log(`🌐 Target domain: \x1b[36m${domain}\x1b[0m`); - console.log(`🔗 Full URL: \x1b[36m${url}\x1b[0m`); - console.log(`\nℹ️ Server's reason:\n\n\x1b[36m${message}\x1b[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(`\x1b[31m❌ Elicitation ${elicitationId} timed out waiting for completion.\x1b[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?.substring(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(), 15000); - } 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(ElicitRequestSchema, elicitationRequestHandler); - - // Set up notification handler for elicitation completion - client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { - const { elicitationId } = notification.params; - const pending = pendingURLElicitations.get(elicitationId); - if (pending) { - clearTimeout(pending.timeout); - pendingURLElicitations.delete(elicitationId); - console.log(`\x1b[32m✅ Elicitation ${elicitationId} completed!\x1b[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('\x1b[31mClient error:', error, '\x1b[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('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; - } else { - console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); - console.log('Session ID is still active:', transport.sessionId); - } - } 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, ListToolsResultSchema); - - 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 { - const request: CallToolRequest = { - method: 'tools/call', - params: { - name, - arguments: args - } - }; - - console.log(`Calling tool '${name}' with args:`, args); - const result = await client.request(request, CallToolResultSchema); - - console.log('Tool result:'); - const resourceLinks: ResourceLink[] = []; - - result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else if (item.type === '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}`); - } - } else if (item.type === 'resource') { - console.log(` [Embedded Resource: ${item.resource.uri}]`); - } else if (item.type === 'image') { - console.log(` [Image: ${item.mimeType}]`); - } else if (item.type === 'audio') { - console.log(` [Audio: ${item.mimeType}]`); - } else { - 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!'); - 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 -main().catch((error: unknown) => { - console.error('Error running MCP client:', error); - process.exit(1); -}); diff --git a/src/examples/client/multipleClientsParallel.ts b/src/examples/client/multipleClientsParallel.ts deleted file mode 100644 index 492235cdd..000000000 --- a/src/examples/client/multipleClientsParallel.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '../../types.js'; - -/** - * 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(LoggingMessageNotificationSchema, 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 toolRequest: CallToolRequest = { - method: 'tools/call', - params: { - name: config.toolName, - arguments: { - ...config.toolArguments, - // Add client ID to arguments for identification in notifications - caller: config.id - } - } - }; - - const result = await client.request(toolRequest, CallToolResultSchema); - 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 ==='); - results.forEach(({ id, result }) => { - console.log(`\n[${id}] Tool result:`); - if (Array.isArray(result.content)) { - result.content.forEach((item: { type: string; text?: string }) => { - 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); - process.exit(1); - } -} - -// Start the example -main().catch((error: unknown) => { - console.error('Error running MCP multiple clients example:', error); - process.exit(1); -}); diff --git a/src/examples/client/parallelToolCallsClient.ts b/src/examples/client/parallelToolCallsClient.ts deleted file mode 100644 index 2ad249de7..000000000 --- a/src/examples/client/parallelToolCallsClient.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { - ListToolsRequest, - ListToolsResultSchema, - CallToolResultSchema, - LoggingMessageNotificationSchema, - CallToolResult -} from '../../types.js'; - -/** - * 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(LoggingMessageNotificationSchema, 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} ===`); - result.content.forEach((item: { type: string; text?: string }) => { - 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, 10000)); - - // 4. Disconnect - console.log('\n=== Disconnecting ==='); - await transport.close(); - console.log('Disconnected from MCP server'); - } catch (error) { - console.error('Error running client:', error); - 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, ListToolsResultSchema); - - 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', - request: { - method: 'tools/call', - params: { - name: 'start-notification-stream', - arguments: { - interval: 2, // 0.5 second between notifications - count: 10, // Send 10 notifications - caller: 'fast-notifier' // Identify this tool call - } - } - } - }, - { - caller: 'slow-notifier', - request: { - method: 'tools/call', - params: { - name: 'start-notification-stream', - arguments: { - interval: 5, // 2 seconds between notifications - count: 5, // Send 5 notifications - caller: 'slow-notifier' // Identify this tool call - } - } - } - }, - { - caller: 'burst-notifier', - request: { - method: 'tools/call', - params: { - name: 'start-notification-stream', - arguments: { - 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, request }) => { - console.log(`Starting tool call for ${caller}...`); - return client - .request(request, CallToolResultSchema) - .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 = {}; - results.forEach(({ caller, result }) => { - resultsByTool[caller] = result; - }); - - return resultsByTool; - } catch (error) { - console.error(`Error starting parallel notification tools:`, error); - throw error; - } -} - -// Start the client -main().catch((error: unknown) => { - console.error('Error running MCP client:', error); - process.exit(1); -}); diff --git a/src/examples/client/simpleClientCredentials.ts b/src/examples/client/simpleClientCredentials.ts deleted file mode 100644 index 7defcc41f..000000000 --- a/src/examples/client/simpleClientCredentials.ts +++ /dev/null @@ -1,82 +0,0 @@ -#!/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 { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../client/auth-extensions.js'; -import { OAuthClientProvider } from '../../client/auth.js'; - -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(); -} - -main().catch(err => { - console.error(err); - process.exit(1); -}); diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts deleted file mode 100644 index 8071e61ac..000000000 --- a/src/examples/client/simpleOAuthClient.ts +++ /dev/null @@ -1,458 +0,0 @@ -#!/usr/bin/env node - -import { createServer } from 'node:http'; -import { createInterface } from 'node:readline'; -import { URL } from 'node:url'; -import { exec } from 'node:child_process'; -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { OAuthClientMetadata } from '../../shared/auth.js'; -import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '../../types.js'; -import { UnauthorizedError } from '../../client/auth.js'; -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 async openBrowser(url: string): Promise { - console.log(`🌐 Opening browser for authorization: ${url}`); - - const command = `open "${url}"`; - - exec(command, error => { - if (error) { - console.error(`Failed to open browser: ${error.message}`); - 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?.substring(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, ListToolsResultSchema); - - if (result.tools && result.tools.length > 0) { - console.log('\n📋 Available tools:'); - result.tools.forEach((tool, index) => { - 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 request: CallToolRequest = { - method: 'tools/call', - params: { - name: toolName, - arguments: toolArgs - } - }; - - const result = await this.client.request(request, CallToolResultSchema); - - console.log(`\n🔧 Tool '${toolName}' result:`); - if (result.content) { - result.content.forEach(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 - }, - CallToolResultSchema, - { - task: { - taskId: `task-${Date.now()}`, - ttl: 60000 - } - } - ); - - // 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!'); - message.result.content.forEach(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(); - } -} - -// Run if this file is executed directly -main().catch(error => { - console.error('Unhandled error:', error); - process.exit(1); -}); diff --git a/src/examples/client/simpleOAuthClientProvider.ts b/src/examples/client/simpleOAuthClientProvider.ts deleted file mode 100644 index 3f1932c3e..000000000 --- a/src/examples/client/simpleOAuthClientProvider.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { OAuthClientProvider } from '../../client/auth.js'; -import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; - -/** - * 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 - ) { - 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/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts deleted file mode 100644 index 21ab4f556..000000000 --- a/src/examples/client/simpleStreamableHttp.ts +++ /dev/null @@ -1,924 +0,0 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { createInterface } from 'node:readline'; -import { - ListToolsRequest, - ListToolsResultSchema, - CallToolRequest, - CallToolResultSchema, - ListPromptsRequest, - ListPromptsResultSchema, - GetPromptRequest, - GetPromptResultSchema, - ListResourcesRequest, - ListResourcesResultSchema, - LoggingMessageNotificationSchema, - ResourceListChangedNotificationSchema, - ElicitRequestSchema, - ResourceLink, - ReadResourceRequest, - ReadResourceResultSchema, - RELATED_TASK_META_KEY, - ErrorCode, - McpError -} from '../../types.js'; -import { getDisplayName } from '../../shared/metadataUtils.js'; -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 = undefined; -let sessionId: string | undefined = 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(' 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 'start-notifications': { - const interval = args[1] ? parseInt(args[1], 10) : 2000; - const count = args[2] ? parseInt(args[2], 10) : 10; - await startNotifications(interval, count); - break; - } - - case 'run-notifications-tool-with-resumability': { - const interval = args[1] ? parseInt(args[1], 10) : 2000; - const count = args[2] ? 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 a new client with form elicitation capability - client = new Client( - { - name: 'example-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - form: {} - } - } - } - ); - client.onerror = error => { - console.error('\x1b[31mClient error:', error, '\x1b[0m'); - }; - - // Set up elicitation request handler with proper validation - client.setRequestHandler(ElicitRequestSchema, async request => { - if (request.params.mode !== 'form') { - throw new McpError(ErrorCode.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('Requested Schema:'); - console.log(JSON.stringify(request.params.requestedSchema, null, 2)); - - 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; - } 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; - - if (field.type === 'boolean') { - parsedValue = answer.toLowerCase() === 'true' || answer.toLowerCase() === 'yes' || answer === '1'; - } else if (field.type === 'number') { - parsedValue = parseFloat(answer); - if (isNaN(parsedValue as number)) { - throw new Error(`${fieldName} must be a valid number`); - } - } else if (field.type === 'integer') { - parsedValue = parseInt(answer, 10); - if (isNaN(parsedValue as number)) { - throw new Error(`${fieldName} must be a valid integer`); - } - } else 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; - } - } catch (error) { - console.log(`❌ Error: ${error}`); - // Continue to next attempt - break; - } - } - - if (inputCancelled) { - return { 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 { action: 'decline' }; - } - } - - // Validate the complete object against the schema - const isValid = validate(content); - - if (!isValid) { - console.log('❌ Validation errors:'); - validate.errors?.forEach(error => { - 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 { 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()); - }); - }); - - if (confirmAnswer === 'yes' || confirmAnswer === 'y') { - return { - action: 'accept', - content - }; - } else if (confirmAnswer === 'cancel' || confirmAnswer === 'c') { - return { action: 'cancel' }; - } else if (confirmAnswer === 'no' || confirmAnswer === 'n') { - if (attempts < maxAttempts) { - console.log('Please re-enter the information...'); - continue; - } else { - return { action: 'decline' }; - } - } - } - - console.log('Maximum attempts reached. Declining request.'); - return { action: 'decline' }; - }); - - transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - sessionId: sessionId - }); - - // Set up notification handlers - client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - notificationCount++; - console.log(`\nNotification #${notificationCount}: ${notification.params.level} - ${notification.params.data}`); - // Re-display the prompt - process.stdout.write('> '); - }); - - client.setNotificationHandler(ResourceListChangedNotificationSchema, 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: {} - }, - ListResourcesResultSchema - ); - 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('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; - } else { - console.log('Server responded with 405 Method Not Allowed (session termination not supported)'); - console.log('Session ID is still active:', transport.sessionId); - } - } 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, ListToolsResultSchema); - - 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 { - const request: CallToolRequest = { - method: 'tools/call', - params: { - name, - arguments: args - } - }; - - console.log(`Calling tool '${name}' with args:`, args); - const result = await client.request(request, CallToolResultSchema); - - console.log('Tool result:'); - const resourceLinks: ResourceLink[] = []; - - result.content.forEach(item => { - if (item.type === 'text') { - console.log(` ${item.text}`); - } else if (item.type === '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}`); - } - } else if (item.type === 'resource') { - console.log(` [Embedded Resource: ${item.resource.uri}]`); - } else if (item.type === 'image') { - console.log(` [Image: ${item.mimeType}]`); - } else if (item.type === 'audio') { - console.log(` [Audio: ${item.mimeType}]`); - } else { - 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 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 request: CallToolRequest = { - method: 'tools/call', - params: { - name: 'start-notification-stream', - arguments: { interval, count } - } - }; - - const onLastEventIdUpdate = (event: string) => { - notificationsToolLastEventId = event; - console.log(`Updated resumption token: ${event}`); - }; - - const result = await client.request(request, CallToolResultSchema, { - resumptionToken: notificationsToolLastEventId, - onresumptiontoken: onLastEventIdUpdate - }); - - console.log('Tool result:'); - result.content.forEach(item => { - 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, ListPromptsResultSchema); - 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, GetPromptResultSchema); - console.log('Prompt template:'); - promptResult.messages.forEach((msg, index) => { - 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, ListResourcesResultSchema); - - 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, ReadResourceResultSchema); - - 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 - }, - CallToolResultSchema, - { - task: { - ttl: 60000 // 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:'); - message.result.content.forEach(item => { - 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!'); - 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 -main().catch((error: unknown) => { - console.error('Error running MCP client:', error); - process.exit(1); -}); diff --git a/src/examples/client/simpleTaskInteractiveClient.ts b/src/examples/client/simpleTaskInteractiveClient.ts deleted file mode 100644 index 06ed0ead1..000000000 --- a/src/examples/client/simpleTaskInteractiveClient.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * 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 { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { createInterface } from 'node:readline'; -import { - CallToolResultSchema, - TextContent, - ElicitRequestSchema, - CreateMessageRequestSchema, - CreateMessageRequest, - CreateMessageResult, - ErrorCode, - McpError -} from '../../types.js'; - -// 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: string; 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(ElicitRequestSchema, async request => { - if (request.params.mode && request.params.mode !== 'form') { - throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); - } - return elicitationCallback(request.params); - }); - - // Set up sampling request handler - client.setRequestHandler(CreateMessageRequestSchema, 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' } }, - CallToolResultSchema, - { task: { ttl: 60000 } } - ); - - 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': - console.log(`Result: ${getTextContent(message.result)}`); - 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' } }, - CallToolResultSchema, - { - task: { ttl: 60000 } - } - ); - - 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': - console.log(`Result:\n${getTextContent(message.result)}`); - 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 -run(url).catch(error => { - console.error('Error running client:', error); - process.exit(1); -}); diff --git a/src/examples/client/ssePollingClient.ts b/src/examples/client/ssePollingClient.ts deleted file mode 100644 index ac7bba37d..000000000 --- a/src/examples/client/ssePollingClient.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 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: npx tsx src/examples/client/ssePollingClient.ts - * Requires: ssePollingExample.ts server running on port 3001 - */ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../types.js'; - -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(LoggingMessageNotificationSchema, 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: {} - } - }, - CallToolResultSchema, - { - // 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'); - } -} - -main().catch(console.error); diff --git a/src/examples/client/streamableHttpWithSseFallbackClient.ts b/src/examples/client/streamableHttpWithSseFallbackClient.ts deleted file mode 100644 index 657f48953..000000000 --- a/src/examples/client/streamableHttpWithSseFallbackClient.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Client } from '../../client/index.js'; -import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { SSEClientTransport } from '../../client/sse.js'; -import { - ListToolsRequest, - ListToolsResultSchema, - CallToolRequest, - CallToolResultSchema, - LoggingMessageNotificationSchema -} from '../../types.js'; - -/** - * 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(LoggingMessageNotificationSchema, 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); - 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, ListToolsResultSchema); - - 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 { - // Call the notification tool using reasonable defaults - const request: CallToolRequest = { - method: 'tools/call', - params: { - name: 'start-notification-stream', - arguments: { - interval: 1000, // 1 second between notifications - count: 5 // Send 5 notifications - } - } - }; - - console.log('Calling notification tool...'); - const result = await client.request(request, CallToolResultSchema); - - console.log('Tool result:'); - result.content.forEach(item => { - 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 -main().catch((error: unknown) => { - console.error('Error running MCP client:', error); - process.exit(1); -}); diff --git a/src/examples/server/README-simpleTaskInteractive.md b/src/examples/server/README-simpleTaskInteractive.md deleted file mode 100644 index 6e8cd345b..000000000 --- a/src/examples/server/README-simpleTaskInteractive.md +++ /dev/null @@ -1,161 +0,0 @@ -# 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 the SDK root directory -npx tsx src/examples/server/simpleTaskInteractive.ts - -# Or with a custom port -PORT=9000 npx tsx src/examples/server/simpleTaskInteractive.ts -``` - -The server will start on http://localhost:8000/mcp (or your custom port). - -### Run the Client - -```bash -# From the SDK root directory -npx tsx src/examples/client/simpleTaskInteractiveClient.ts - -# Or connect to a different server -npx tsx src/examples/client/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 - -- `src/shared/task.ts` - Core task interfaces (TaskStore, TaskMessageQueue) -- `src/examples/shared/inMemoryTaskStore.ts` - In-memory implementations -- `src/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts deleted file mode 100644 index 1abc040ce..000000000 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; -import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; -import express, { Request, Response } from 'express'; -import { AuthInfo } from '../../server/auth/types.js'; -import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; -import { InvalidRequestError } from '../../server/auth/errors.js'; - -export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); - - async getClient(clientId: string) { - return this.clients.get(clientId); - } - - async registerClient(clientMetadata: OAuthClientInformationFull) { - this.clients.set(clientMetadata.client_id, clientMetadata); - return clientMetadata; - } -} - -/** - * 🚨 DEMO ONLY - NOT FOR PRODUCTION - * - * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, - * for example: - * - Persistent token storage - * - Rate limiting - */ -export class DemoInMemoryAuthProvider implements OAuthServerProvider { - clientsStore = new DemoInMemoryClientsStore(); - private codes = new Map< - string, - { - params: AuthorizationParams; - client: OAuthClientInformationFull; - } - >(); - private tokens = new Map(); - - constructor(private validateResource?: (resource?: URL) => boolean) {} - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - const code = randomUUID(); - - const searchParams = new URLSearchParams({ - code - }); - if (params.state !== undefined) { - searchParams.set('state', params.state); - } - - this.codes.set(code, { - client, - params - }); - - // Simulate a user login - // Set a secure HTTP-only session cookie with authorization info - if (res.cookie) { - const authCookieData = { - userId: 'demo_user', - name: 'Demo User', - timestamp: Date.now() - }; - res.cookie('demo_session', JSON.stringify(authCookieData), { - httpOnly: true, - secure: false, // In production, this should be true - sameSite: 'lax', - maxAge: 24 * 60 * 60 * 1000, // 24 hours - for demo purposes - path: '/' // Available to all routes - }); - } - - if (!client.redirect_uris.includes(params.redirectUri)) { - throw new InvalidRequestError('Unregistered redirect_uri'); - } - const targetUrl = new URL(params.redirectUri); - targetUrl.search = searchParams.toString(); - res.redirect(targetUrl.toString()); - } - - async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { - // Store the challenge with the code data - const codeData = this.codes.get(authorizationCode); - if (!codeData) { - throw new Error('Invalid authorization code'); - } - - return codeData.params.codeChallenge; - } - - async exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - // Note: code verifier is checked in token.ts by default - // it's unused here for that reason. - _codeVerifier?: string - ): Promise { - const codeData = this.codes.get(authorizationCode); - if (!codeData) { - throw new Error('Invalid authorization code'); - } - - if (codeData.client.client_id !== client.client_id) { - throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); - } - - if (this.validateResource && !this.validateResource(codeData.params.resource)) { - throw new Error(`Invalid resource: ${codeData.params.resource}`); - } - - this.codes.delete(authorizationCode); - const token = randomUUID(); - - const tokenData = { - token, - clientId: client.client_id, - scopes: codeData.params.scopes || [], - expiresAt: Date.now() + 3600000, // 1 hour - resource: codeData.params.resource, - type: 'access' - }; - - this.tokens.set(token, tokenData); - - return { - access_token: token, - token_type: 'bearer', - expires_in: 3600, - scope: (codeData.params.scopes || []).join(' ') - }; - } - - async exchangeRefreshToken( - _client: OAuthClientInformationFull, - _refreshToken: string, - _scopes?: string[], - _resource?: URL - ): Promise { - throw new Error('Not implemented for example demo'); - } - - async verifyAccessToken(token: string): Promise { - const tokenData = this.tokens.get(token); - if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { - throw new Error('Invalid or expired token'); - } - - return { - token, - clientId: tokenData.clientId, - scopes: tokenData.scopes, - expiresAt: Math.floor(tokenData.expiresAt / 1000), - resource: tokenData.resource - }; - } -} - -export const setupAuthServer = ({ - authServerUrl, - mcpServerUrl, - strictResource -}: { - authServerUrl: URL; - mcpServerUrl: URL; - strictResource: boolean; -}): OAuthMetadata => { - // Create separate auth server app - // NOTE: This is a separate app on a separate port to illustrate - // how to separate an OAuth Authorization Server from a Resource - // server in the SDK. The SDK is not intended to be provide a standalone - // authorization server. - - const validateResource = strictResource - ? (resource?: URL) => { - if (!resource) return false; - const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); - return resource.toString() === expectedResource.toString(); - } - : undefined; - - const provider = new DemoInMemoryAuthProvider(validateResource); - const authApp = express(); - authApp.use(express.json()); - // For introspection requests - authApp.use(express.urlencoded()); - - // Add OAuth routes to the auth server - // NOTE: this will also add a protected resource metadata route, - // but it won't be used, so leave it. - authApp.use( - mcpAuthRouter({ - provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'] - }) - ); - - authApp.post('/introspect', async (req: Request, res: Response) => { - try { - const { token } = req.body; - if (!token) { - res.status(400).json({ error: 'Token is required' }); - return; - } - - const tokenInfo = await provider.verifyAccessToken(token); - res.json({ - active: true, - client_id: tokenInfo.clientId, - scope: tokenInfo.scopes.join(' '), - exp: tokenInfo.expiresAt, - aud: tokenInfo.resource - }); - return; - } catch (error) { - res.status(401).json({ - active: false, - error: 'Unauthorized', - error_description: `Invalid token: ${error}` - }); - } - }); - - const auth_port = authServerUrl.port; - // Start the auth server - authApp.listen(auth_port, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`OAuth Authorization Server listening on port ${auth_port}`); - }); - - // Note: we could fetch this from the server, but then we end up - // with some top level async which gets annoying. - const oauthMetadata: OAuthMetadata = createOAuthMetadata({ - provider, - issuerUrl: authServerUrl, - scopesSupported: ['mcp:tools'] - }); - - oauthMetadata.introspection_endpoint = new URL('/introspect', authServerUrl).href; - - return oauthMetadata; -}; diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts deleted file mode 100644 index 6c0800949..000000000 --- a/src/examples/server/elicitationFormExample.ts +++ /dev/null @@ -1,471 +0,0 @@ -// Run with: npx tsx src/examples/server/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 { type Request, type Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { isInitializeRequest } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -// Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults -// The validator supports format validation (email, date, etc.) if ajv-formats is installed -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', - inputSchema: {} - }, - 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', - inputSchema: {} - }, - 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', - inputSchema: {} - }, - 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 - }; - } - } -); - -async function main() { - const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; - - const app = createMcpExpressApp(); - - // Map to store transports by session ID - const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - - // 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: StreamableHTTPServerTransport; - 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 StreamableHTTPServerTransport({ - 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 the transport to the MCP server BEFORE handling the request - await mcpServer.connect(transport); - - await transport.handleRequest(req, res, req.body); - return; - } else { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - 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: -32603, - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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); - 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); - }); -} - -main().catch(error => { - console.error('Server error:', error); - process.exit(1); -}); diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts deleted file mode 100644 index 5ddecc4e1..000000000 --- a/src/examples/server/elicitationUrlExample.ts +++ /dev/null @@ -1,771 +0,0 @@ -// Run with: npx tsx src/examples/server/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 express, { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; -import { McpServer } from '../../server/mcp.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; -import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { CallToolResult, UrlElicitationRequiredError, ElicitRequestURLParams, ElicitResult, isInitializeRequest } from '../../types.js'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; -import { OAuthMetadata } from '../../shared/auth.js'; -import { checkResourceAllowed } from '../../shared/auth-utils.js'; - -import cors from 'cors'; - -// 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: { - cartId: z.string().describe('The ID of the cart to confirm') - } - }, - async ({ cartId }, extra): 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 = extra.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: { - param1: z.string().describe('First parameter') - } - }, - async (_, extra): 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 `extra.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 = extra.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 ? parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? 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}`); - -const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: true }); - -const tokenVerifier = { - verifyAccessToken: async (token: string) => { - const endpoint = oauthMetadata.introspection_endpoint; - - if (!endpoint) { - throw new Error('No token verification endpoint available in metadata'); - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - token: token - }).toString() - }); - - if (!response.ok) { - const text = await response.text().catch(() => null); - throw new Error(`Invalid or expired token: ${text}`); - } - - const data = await response.json(); - - if (!data.aud) { - throw new Error(`Resource Indicator (RFC8707) missing`); - } - if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); - } - - // Convert the response to AuthInfo format - return { - token, - clientId: data.client_id, - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp - }; - } -}; -// Add metadata routes to the main MCP server -app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpServerUrl, - scopesSupported: ['mcp:tools'], - resourceName: 'MCP Demo Server' - }) -); - -authMiddleware = requireBearerAuth({ - verifier: tokenVerifier, - 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 \x1b[32m${apiKey}\x1b[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]: StreamableHTTPServerTransport } = {}; - -// 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: StreamableHTTPServerTransport; - 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 StreamableHTTPServerTransport({ - 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 { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - 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: -32603, - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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); - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); -}); - -// 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/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts deleted file mode 100644 index 224955c46..000000000 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -// 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.tool( - 'greet', - 'A simple greeting tool', - { - 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.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet') - }, - async ({ name }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await server.sendLoggingMessage( - { - level: 'debug', - data: `Starting multi-greet for ${name}` - }, - extra.sessionId - ); - - await sleep(1000); // Wait 1 second before first greeting - - await server.sendLoggingMessage( - { - level: 'info', - data: `Sending first greeting to ${name}` - }, - extra.sessionId - ); - - await sleep(1000); // Wait another second before second greeting - - await server.sendLoggingMessage( - { - level: 'info', - data: `Sending second greeting to ${name}` - }, - extra.sessionId - ); - - return { - content: [ - { - type: 'text', - text: `Good morning, ${name}!` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - -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: StreamableHTTPServerTransport; - - 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 StreamableHTTPServerTransport({ - 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 { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - 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: -32603, - 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); - 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/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts deleted file mode 100644 index 7ef9f6227..000000000 --- a/src/examples/server/mcpServerOutputSchema.ts +++ /dev/null @@ -1,80 +0,0 @@ -#!/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 '../../server/mcp.js'; -import { StdioServerTransport } from '../../server/stdio.js'; -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: { - city: z.string().describe('City name'), - country: z.string().describe('Country code (e.g., US, UK)') - }, - outputSchema: { - 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'); -} - -main().catch(error => { - console.error('Server error:', error); - process.exit(1); -}); diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts deleted file mode 100644 index 1cd10cd2d..000000000 --- a/src/examples/server/simpleSseServer.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { Request, Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { SSEServerTransport } from '../../server/sse.js'; -import * as z from 'zod/v4'; -import { CallToolResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -/** - * This example server demonstrates the deprecated HTTP+SSE transport - * (protocol version 2024-11-05). It mainly used for testing backward compatible clients. - * - * The server exposes two endpoints: - * - /mcp: For establishing the SSE stream (GET) - * - /messages: For receiving client messages (POST) - * - */ - -// Create an MCP server instance -const getServer = () => { - const server = new McpServer( - { - name: 'simple-sse-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications', - { - interval: z.number().describe('Interval in milliseconds between notifications').default(1000), - count: z.number().describe('Number of notifications to send').default(10) - }, - async ({ interval, count }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - // Send the initial notification - await server.sendLoggingMessage( - { - level: 'info', - data: `Starting notification stream with ${count} messages every ${interval}ms` - }, - extra.sessionId - ); - - // Send periodic notifications - while (counter < count) { - counter++; - await sleep(interval); - - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } catch (error) { - console.error('Error sending notification:', error); - } - } - - return { - content: [ - { - type: 'text', - text: `Completed sending ${count} notifications every ${interval}ms` - } - ] - }; - } - ); - return server; -}; - -const app = createMcpExpressApp(); - -// Store transports by session ID -const transports: Record = {}; - -// SSE endpoint for establishing the stream -app.get('/mcp', async (req: Request, res: Response) => { - console.log('Received GET request to /sse (establishing SSE stream)'); - - try { - // Create a new SSE transport for the client - // The endpoint for POST messages is '/messages' - const transport = new SSEServerTransport('/messages', res); - - // Store the transport by session ID - const sessionId = transport.sessionId; - transports[sessionId] = transport; - - // Set up onclose handler to clean up transport when closed - transport.onclose = () => { - console.log(`SSE transport closed for session ${sessionId}`); - delete transports[sessionId]; - }; - - // Connect the transport to the MCP server - const server = getServer(); - await server.connect(transport); - - console.log(`Established SSE stream with session ID: ${sessionId}`); - } catch (error) { - console.error('Error establishing SSE stream:', error); - if (!res.headersSent) { - res.status(500).send('Error establishing SSE stream'); - } - } -}); - -// Messages endpoint for receiving client JSON-RPC requests -app.post('/messages', async (req: Request, res: Response) => { - console.log('Received POST request to /messages'); - - // Extract session ID from URL query parameter - // In the SSE protocol, this is added by the client based on the endpoint event - const sessionId = req.query.sessionId as string | undefined; - - if (!sessionId) { - console.error('No session ID provided in request URL'); - res.status(400).send('Missing sessionId parameter'); - return; - } - - const transport = transports[sessionId]; - if (!transport) { - console.error(`No active transport found for session ID: ${sessionId}`); - res.status(404).send('Session not found'); - return; - } - - try { - // Handle the POST message with the transport - await transport.handlePostMessage(req, res, req.body); - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) { - res.status(500).send('Error handling request'); - } - } -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`Simple SSE Server (deprecated protocol version 2024-11-05) listening on port ${PORT}`); -}); - -// 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/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts deleted file mode 100644 index 748d82fda..000000000 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Request, Response } from 'express'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import * as z from 'zod/v4'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -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.prompt( - 'greeting-template', - 'A simple greeting prompt template', - { - 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.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - 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 }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } 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.resource( - '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: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({ - 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: -32603, - 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: -32000, - 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: -32000, - 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); - 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...'); - process.exit(0); -}); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts deleted file mode 100644 index ca1363198..000000000 --- a/src/examples/server/simpleStreamableHttp.ts +++ /dev/null @@ -1,751 +0,0 @@ -import { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import * as z from 'zod/v4'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; -import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { - CallToolResult, - ElicitResultSchema, - GetPromptResult, - isInitializeRequest, - PrimitiveSchemaDefinition, - ReadResourceResult, - ResourceLink -} from '../../types.js'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../experimental/tasks/stores/in-memory.js'; -import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; -import { OAuthMetadata } from '../../shared/auth.js'; -import { checkResourceAllowed } from '../../shared/auth-utils.js'; - -// Check for OAuth flag -const useOAuth = process.argv.includes('--oauth'); -const strictOAuth = process.argv.includes('--oauth-strict'); - -// 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, // Enable task support - 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: { - 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.tool( - 'multi-greet', - 'A tool that sends different greetings with delays between them', - { - name: z.string().describe('Name to greet') - }, - { - title: 'Multiple Greeting Tool', - readOnlyHint: true, - openWorldHint: false - }, - async ({ name }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - await server.sendLoggingMessage( - { - level: 'debug', - data: `Starting multi-greet for ${name}` - }, - extra.sessionId - ); - - await sleep(1000); // Wait 1 second before first greeting - - await server.sendLoggingMessage( - { - level: 'info', - data: `Sending first greeting to ${name}` - }, - extra.sessionId - ); - - await sleep(1000); // Wait another second before second greeting - - await server.sendLoggingMessage( - { - level: 'info', - data: `Sending second greeting to ${name}` - }, - extra.sessionId - ); - - 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.tool( - 'collect-user-info', - 'A tool that collects user information through form elicitation', - { - infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') - }, - async ({ infoType }, extra): 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 extra parameter to elicit input - const result = await extra.sendRequest( - { - method: 'elicitation/create', - params: { - mode: 'form', - message, - requestedSchema - } - }, - ElicitResultSchema - ); - - 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: { - 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.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - 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 }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } 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: { - 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: { - duration: z.number().describe('Duration in milliseconds').default(5000) - } - }, - { - async createTask({ duration }, { taskStore, taskRequestedTtl }) { - // Create the task - const task = await taskStore.createTask({ - ttl: taskRequestedTtl - }); - - // Simulate out-of-band work - (async () => { - await new Promise(resolve => setTimeout(resolve, duration)); - await taskStore.storeTaskResult(task.taskId, 'completed', { - content: [ - { - type: 'text', - text: `Completed ${duration}ms delay` - } - ] - }); - })(); - - // Return CreateTaskResult with the created task - return { - task - }; - }, - async getTask(_args, { taskId, taskStore }) { - return await taskStore.getTask(taskId); - }, - async getTaskResult(_args, { taskId, taskStore }) { - const result = await taskStore.getTaskResult(taskId); - return result as CallToolResult; - } - } - ); - - return server; -}; - -const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; -const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; - -const app = createMcpExpressApp(); - -// 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}`); - - const oauthMetadata: OAuthMetadata = setupAuthServer({ authServerUrl, mcpServerUrl, strictResource: strictOAuth }); - - const tokenVerifier = { - verifyAccessToken: async (token: string) => { - const endpoint = oauthMetadata.introspection_endpoint; - - if (!endpoint) { - throw new Error('No token verification endpoint available in metadata'); - } - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - token: token - }).toString() - }); - - if (!response.ok) { - const text = await response.text().catch(() => null); - throw new Error(`Invalid or expired token: ${text}`); - } - - const data = await response.json(); - - if (strictOAuth) { - if (!data.aud) { - throw new Error(`Resource Indicator (RFC8707) missing`); - } - if (!checkResourceAllowed({ requestedResource: data.aud, configuredResource: mcpServerUrl })) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); - } - } - - // Convert the response to AuthInfo format - return { - token, - clientId: data.client_id, - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp - }; - } - }; - // Add metadata routes to the main MCP server - app.use( - mcpAuthMetadataRouter({ - oauthMetadata, - resourceServerUrl: mcpServerUrl, - scopesSupported: ['mcp:tools'], - resourceName: 'MCP Demo Server' - }) - ); - - authMiddleware = requireBearerAuth({ - verifier: tokenVerifier, - requiredScopes: [], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - }); -} - -// Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - -// 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: StreamableHTTPServerTransport; - 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 StreamableHTTPServerTransport({ - 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 { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - 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: -32603, - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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); - process.exit(1); - } - console.log(`MCP Streamable HTTP Server listening on port ${MCP_PORT}`); -}); - -// 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/src/examples/server/simpleTaskInteractive.ts b/src/examples/server/simpleTaskInteractive.ts deleted file mode 100644 index db0a4b579..000000000 --- a/src/examples/server/simpleTaskInteractive.ts +++ /dev/null @@ -1,745 +0,0 @@ -/** - * 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 { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { Server } from '../../server/index.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { - CallToolResult, - CreateTaskResult, - GetTaskResult, - Tool, - TextContent, - RELATED_TASK_META_KEY, - Task, - Result, - RequestId, - JSONRPCRequest, - SamplingMessage, - ElicitRequestFormParams, - CreateMessageRequest, - ElicitResult, - CreateMessageResult, - PrimitiveSchemaDefinition, - ListToolsRequestSchema, - CallToolRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - GetTaskPayloadResult -} from '../../types.js'; -import { TaskMessageQueue, QueuedMessage, QueuedRequest, isTerminal, CreateTaskOptions } from '../../experimental/tasks/interfaces.js'; -import { InMemoryTaskStore } from '../../experimental/tasks/stores/in-memory.js'; - -// ============================================================================ -// 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)[]>(); - - async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise { - await super.updateTaskStatus(taskId, status, statusMessage, sessionId); - this.notifyUpdate(taskId); - } - - 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 ? 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(ListToolsRequestSchema, 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(CallToolRequestSchema, async (request, extra): 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, extra.requestId, request, extra.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.substring(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: extra.sessionId ?? '' - }); - - return { task }; - }); - - // Handle tasks/get - server.setRequestHandler(GetTaskRequestSchema, 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(GetTaskPayloadRequestSchema, async (request, extra): Promise => { - const { taskId } = request.params; - console.log(`[Server] tasks/result called for task ${taskId}`); - return taskResultHandler.handle(taskId, server, extra.sessionId ?? ''); - }); - - return server; -}; - -// ============================================================================ -// Express App Setup -// ============================================================================ - -const app = createMcpExpressApp(); - -// Map to store transports by session ID -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - -// 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: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - transport = new StreamableHTTPServerTransport({ - 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 { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session ID' }, - 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: -32603, 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts deleted file mode 100644 index 5c91b7e33..000000000 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { SSEServerTransport } from '../../server/sse.js'; -import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '../../types.js'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -/** - * This example server demonstrates backwards compatibility with both: - * 1. The deprecated HTTP+SSE transport (protocol version 2024-11-05) - * 2. The Streamable HTTP transport (protocol version 2025-03-26) - * - * It maintains a single MCP server instance but exposes two transport options: - * - /mcp: The new Streamable HTTP endpoint (supports GET/POST/DELETE) - * - /sse: The deprecated SSE endpoint for older clients (GET to establish stream) - * - /messages: The deprecated POST endpoint for older clients (POST to send messages) - */ - -const getServer = () => { - const server = new McpServer( - { - name: 'backwards-compatible-server', - version: '1.0.0' - }, - { capabilities: { logging: {} } } - ); - - // Register a simple tool that sends notifications over time - server.tool( - 'start-notification-stream', - 'Starts sending periodic notifications for testing resumability', - { - 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 }, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - let counter = 0; - - while (count === 0 || counter < count) { - counter++; - try { - await server.sendLoggingMessage( - { - level: 'info', - data: `Periodic notification #${counter} at ${new Date().toISOString()}` - }, - extra.sessionId - ); - } 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` - } - ] - }; - } - ); - return server; -}; - -// Create Express application -const app = createMcpExpressApp(); - -// Store transports by session ID -const transports: Record = {}; - -//============================================================================= -// STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) -//============================================================================= - -// Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint -app.all('/mcp', async (req: Request, res: Response) => { - console.log(`Received ${req.method} request to /mcp`); - - try { - // Check for existing session ID - const sessionId = req.headers['mcp-session-id'] as string | undefined; - let transport: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Check if the transport is of the correct type - const existingTransport = transports[sessionId]; - if (existingTransport instanceof StreamableHTTPServerTransport) { - // Reuse existing transport - transport = existingTransport; - } else { - // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Session exists but uses a different transport protocol' - }, - id: null - }); - return; - } - } else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) { - const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, // Enable resumability - onsessioninitialized: sessionId => { - // Store the transport by session ID when session is initialized - console.log(`StreamableHTTP 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 - const server = getServer(); - await server.connect(transport); - } else { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - id: null - }); - return; - } - - // Handle the request with the 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: -32603, - message: 'Internal server error' - }, - id: null - }); - } - } -}); - -//============================================================================= -// DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) -//============================================================================= - -app.get('/sse', async (req: Request, res: Response) => { - console.log('Received GET request to /sse (deprecated SSE transport)'); - const transport = new SSEServerTransport('/messages', res); - transports[transport.sessionId] = transport; - res.on('close', () => { - delete transports[transport.sessionId]; - }); - const server = getServer(); - await server.connect(transport); -}); - -app.post('/messages', async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - let transport: SSEServerTransport; - const existingTransport = transports[sessionId]; - if (existingTransport instanceof SSEServerTransport) { - // Reuse existing transport - transport = existingTransport; - } else { - // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Session exists but uses a different transport protocol' - }, - id: null - }); - return; - } - if (transport) { - await transport.handlePostMessage(req, res, req.body); - } else { - res.status(400).send('No transport found for sessionId'); - } -}); - -// Start the server -const PORT = 3000; -app.listen(PORT, error => { - if (error) { - console.error('Failed to start server:', error); - process.exit(1); - } - console.log(`Backwards compatible MCP server listening on port ${PORT}`); - console.log(` -============================================== -SUPPORTED TRANSPORT OPTIONS: - -1. Streamable Http(Protocol version: 2025-03-26) - Endpoint: /mcp - Methods: GET, POST, DELETE - Usage: - - Initialize with POST to /mcp - - Establish SSE stream with GET to /mcp - - Send requests with POST to /mcp - - Terminate session with DELETE to /mcp - -2. Http + SSE (Protocol version: 2024-11-05) - Endpoints: /sse (GET) and /messages (POST) - Usage: - - Establish SSE stream with GET to /sse - - Send requests with POST to /messages?sessionId= -============================================== -`); -}); - -// 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/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts deleted file mode 100644 index bbecf2fdb..000000000 --- a/src/examples/server/ssePollingExample.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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 `extra.closeSSEStream()` callback to gracefully disconnect clients mid-operation - * - * Run with: npx tsx src/examples/server/ssePollingExample.ts - * Test with: curl or the MCP Inspector - */ -import { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { createMcpExpressApp } from '../../server/express.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { CallToolResult } from '../../types.js'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import cors from 'cors'; - -// Create the MCP server -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.tool( - 'long-task', - 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.', - {}, - async (_args, extra): Promise => { - const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - - console.log(`[${extra.sessionId}] Starting long-task...`); - - // Send first progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 25% - Starting work...' - }, - extra.sessionId - ); - await sleep(1000); - - // Send second progress notification - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 50% - Halfway there...' - }, - extra.sessionId - ); - 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 extra.closeSSEStream callback - available when eventStore is configured - if (extra.closeSSEStream) { - console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); - extra.closeSSEStream(); - } - - // Continue processing while client is disconnected - // Events are stored in eventStore and will be replayed on reconnect - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 75% - Almost done (sent while client disconnected)...' - }, - extra.sessionId - ); - - await sleep(500); - await server.sendLoggingMessage( - { - level: 'info', - data: 'Progress: 100% - Complete!' - }, - extra.sessionId - ); - - console.log(`[${extra.sessionId}] Task complete`); - - return { - content: [ - { - type: 'text', - text: 'Long task completed successfully!' - } - ] - }; - } -); - -// 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 StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore, - retryInterval: 2000, // Default retry interval for priming events - onsessioninitialized: id => { - console.log(`[${id}] Session initialized`); - transports.set(id, transport!); - } - }); - - // Connect the MCP server to the transport - 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/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts deleted file mode 100644 index 546d35c70..000000000 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Request, Response } from 'express'; -import { randomUUID } from 'node:crypto'; -import { McpServer } from '../../server/mcp.js'; -import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { isInitializeRequest, ReadResourceResult } from '../../types.js'; -import { createMcpExpressApp } from '../../server/express.js'; - -// Create an MCP server with implementation details -const server = new McpServer({ - name: 'resource-list-changed-notification-server', - version: '1.0.0' -}); - -// Store transports by session ID to send notifications -const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; - -const addResource = (name: string, content: string) => { - const uri = `https://mcp-example.com/dynamic/${encodeURIComponent(name)}`; - server.resource( - name, - uri, - { mimeType: 'text/plain', description: `Dynamic resource: ${name}` }, - async (): Promise => { - return { - contents: [{ uri, text: content }] - }; - } - ); -}; - -addResource('example-resource', 'Initial content for example-resource'); - -const resourceChangeInterval = setInterval(() => { - const name = randomUUID(); - addResource(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: StreamableHTTPServerTransport; - - if (sessionId && transports[sessionId]) { - // Reuse existing transport - transport = transports[sessionId]; - } else if (!sessionId && isInitializeRequest(req.body)) { - // New initialization request - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - 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 - 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 { - // Invalid request - no session ID or not initialization request - res.status(400).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided' - }, - 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: -32603, - 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 || !transports[sessionId]) { - res.status(400).send('Invalid or missing session ID'); - 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); - process.exit(1); - } - console.log(`Server listening on port ${PORT}`); -}); - -// Handle server shutdown -process.on('SIGINT', async () => { - console.log('Shutting down server...'); - clearInterval(resourceChangeInterval); - await server.close(); - process.exit(0); -}); diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts deleted file mode 100644 index e6d733598..000000000 --- a/src/examples/server/toolWithSampleServer.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Run with: npx tsx src/examples/server/toolWithSampleServer.ts - -import { McpServer } from '../../server/mcp.js'; -import { StdioServerTransport } from '../../server/stdio.js'; -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: { - 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...'); -} - -main().catch(error => { - console.error('Server error:', error); - process.exit(1); -}); diff --git a/src/experimental/index.ts b/src/experimental/index.ts deleted file mode 100644 index 55dd44ed0..000000000 --- a/src/experimental/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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/src/experimental/tasks/client.ts b/src/experimental/tasks/client.ts deleted file mode 100644 index f62941dc8..000000000 --- a/src/experimental/tasks/client.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Experimental client task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { Client } from '../../client/index.js'; -import type { RequestOptions } from '../../shared/protocol.js'; -import type { ResponseMessage } from '../../shared/responseMessage.js'; -import type { AnyObjectSchema, SchemaOutput } from '../../server/zod-compat.js'; -import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '../../types.js'; -import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '../../types.js'; - -import type { GetTaskResult, ListTasksResult, CancelTaskResult } from './types.js'; - -/** - * Internal interface for accessing Client's private methods. - * @internal - */ -interface ClientInternal { - requestStream( - request: ClientRequest | RequestT, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void>; - 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< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> { - constructor(private readonly _client: Client) {} - - /** - * 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 - * ```typescript - * 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 resultSchema - Zod schema for validating the result (defaults to CallToolResultSchema) - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - async *callToolStream( - params: CallToolRequest['params'], - resultSchema: T = CallToolResultSchema as T, - 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 = clientInternal.requestStream({ method: 'tools/call', params }, resultSchema, 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 - if (message.type === 'result' && validator) { - const result = message.result; - - // If tool has outputSchema, it MUST return structuredContent (unless it's an error) - if (!result.structuredContent && !result.isError) { - yield { - type: 'error', - error: new McpError( - ErrorCode.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 McpError( - ErrorCode.InvalidParams, - `Structured content does not match the tool's output schema: ${validationResult.errorMessage}` - ) - }; - return; - } - } catch (error) { - if (error instanceof McpError) { - yield { type: 'error', error }; - return; - } - yield { - type: 'error', - error: new McpError( - ErrorCode.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 { - // Delegate to the client's underlying Protocol method - type ClientWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise }; - return (this._client as unknown as ClientWithGetTask).getTask({ taskId }, options); - } - - /** - * Retrieves the result of a completed task. - * - * @param taskId - The task identifier - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options - * @returns The task result - * - * @experimental - */ - async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { - // Delegate to the client's underlying Protocol method - return ( - this._client as unknown as { - getTaskResult: ( - params: { taskId: string }, - resultSchema?: U, - options?: RequestOptions - ) => Promise>; - } - ).getTaskResult({ taskId }, resultSchema, 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 { - // Delegate to the client's underlying Protocol method - return ( - this._client as unknown as { - listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise; - } - ).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 { - // Delegate to the client's underlying Protocol method - return ( - this._client as unknown as { - cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise; - } - ).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. - * - * @param request - The request to send - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - requestStream( - request: ClientRequest | RequestT, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void> { - // Delegate to the client's underlying Protocol method - type ClientWithRequestStream = { - requestStream( - request: ClientRequest | RequestT, - resultSchema: U, - options?: RequestOptions - ): AsyncGenerator>, void, void>; - }; - return (this._client as unknown as ClientWithRequestStream).requestStream(request, resultSchema, options); - } -} diff --git a/src/experimental/tasks/helpers.ts b/src/experimental/tasks/helpers.ts deleted file mode 100644 index 34b15188f..000000000 --- a/src/experimental/tasks/helpers.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Experimental task capability assertion helpers. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -/** - * 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 by Client.assertTaskCapability and Server.assertTaskHandlerCapability. - * - * @param requests - The task requests capability object - * @param method - The method being checked - * @param entityName - 'Server' or 'Client' for error messages - * @throws Error if the capability is not supported - * - * @experimental - */ -export function assertToolsCallTaskCapability( - requests: TaskRequestsCapability | undefined, - method: string, - entityName: 'Server' | 'Client' -): void { - if (!requests) { - throw new Error(`${entityName} does not support task creation (required for ${method})`); - } - - switch (method) { - case 'tools/call': - if (!requests.tools?.call) { - throw new Error(`${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 by Server.assertTaskCapability and Client.assertTaskHandlerCapability. - * - * @param requests - The task requests capability object - * @param method - The method being checked - * @param entityName - 'Server' or 'Client' for error messages - * @throws Error if the capability is not supported - * - * @experimental - */ -export function assertClientRequestTaskCapability( - requests: TaskRequestsCapability | undefined, - method: string, - entityName: 'Server' | 'Client' -): void { - if (!requests) { - throw new Error(`${entityName} does not support task creation (required for ${method})`); - } - - switch (method) { - case 'sampling/createMessage': - if (!requests.sampling?.createMessage) { - throw new Error(`${entityName} does not support task creation for sampling/createMessage (required for ${method})`); - } - break; - - case 'elicitation/create': - if (!requests.elicitation?.create) { - throw new Error(`${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/src/experimental/tasks/index.ts b/src/experimental/tasks/index.ts deleted file mode 100644 index 398d34393..000000000 --- a/src/experimental/tasks/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Experimental task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -// Re-export spec types for convenience -export * from './types.js'; - -// SDK implementation interfaces -export * from './interfaces.js'; - -// Assertion helpers -export * from './helpers.js'; - -// Wrapper classes -export * from './client.js'; -export * from './server.js'; -export * from './mcp-server.js'; - -// Store implementations -export * from './stores/in-memory.js'; - -// Re-export response message types for task streaming -export type { - ResponseMessage, - TaskStatusMessage, - TaskCreatedMessage, - ResultMessage, - ErrorMessage, - BaseResponseMessage -} from '../../shared/responseMessage.js'; -export { takeResult, toArrayAsync } from '../../shared/responseMessage.js'; diff --git a/src/experimental/tasks/interfaces.ts b/src/experimental/tasks/interfaces.ts deleted file mode 100644 index 88e53028b..000000000 --- a/src/experimental/tasks/interfaces.ts +++ /dev/null @@ -1,289 +0,0 @@ -/** - * Experimental task interfaces for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - */ - -import { - Task, - RequestId, - Result, - JSONRPCRequest, - JSONRPCNotification, - JSONRPCResultResponse, - JSONRPCErrorResponse, - ServerRequest, - ServerNotification, - CallToolResult, - GetTaskResult, - ToolExecution, - Request -} from '../../types.js'; -import { CreateTaskResult } from './types.js'; -import type { RequestHandlerExtra, RequestTaskStore } from '../../shared/protocol.js'; -import type { ZodRawShapeCompat, AnySchema, ShapeOutput } from '../../server/zod-compat.js'; - -// ============================================================================ -// Task Handler Types (for registerToolTask) -// ============================================================================ - -/** - * Extended handler extra with task store for task creation. - * @experimental - */ -export interface CreateTaskRequestHandlerExtra extends RequestHandlerExtra { - taskStore: RequestTaskStore; -} - -/** - * Extended handler extra with task ID and store for task operations. - * @experimental - */ -export interface TaskRequestHandlerExtra extends RequestHandlerExtra { - taskId: string; - taskStore: RequestTaskStore; -} - -/** - * Base callback type for tool handlers. - * @experimental - */ -export type BaseToolCallback< - SendResultT extends Result, - ExtraT extends RequestHandlerExtra, - Args extends undefined | ZodRawShapeCompat | AnySchema = undefined -> = Args extends ZodRawShapeCompat - ? (args: ShapeOutput, extra: ExtraT) => SendResultT | Promise - : Args extends AnySchema - ? (args: unknown, extra: ExtraT) => SendResultT | Promise - : (extra: ExtraT) => SendResultT | Promise; - -/** - * Handler for creating a task. - * @experimental - */ -export type CreateTaskRequestHandler< - SendResultT extends Result, - Args extends undefined | ZodRawShapeCompat | AnySchema = undefined -> = BaseToolCallback; - -/** - * Handler for task operations (get, getResult). - * @experimental - */ -export type TaskRequestHandler< - SendResultT extends Result, - Args extends undefined | ZodRawShapeCompat | AnySchema = undefined -> = BaseToolCallback; - -/** - * Interface for task-based tool handlers. - * @experimental - */ -export interface ToolTaskHandler { - createTask: CreateTaskRequestHandler; - getTask: TaskRequestHandler; - getTaskResult: TaskRequestHandler; -} - -/** - * 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 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 QueuedMessage must be JSON-serializable. - * - * @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 { - /** - * Time in milliseconds to keep task results available after completion. - * 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 Transport, this allows pluggable task storage implementations - * (in-memory, database, distributed cache, etc.). - * - * @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 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 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 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/src/experimental/tasks/mcp-server.ts b/src/experimental/tasks/mcp-server.ts deleted file mode 100644 index 506f3d72b..000000000 --- a/src/experimental/tasks/mcp-server.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Experimental McpServer task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; -import type { ZodRawShapeCompat, AnySchema } from '../../server/zod-compat.js'; -import type { ToolAnnotations, ToolExecution } from '../../types.js'; -import type { ToolTaskHandler, TaskToolExecution } from './interfaces.js'; - -/** - * Internal interface for accessing McpServer's private _createRegisteredTool method. - * @internal - */ -interface McpServerInternal { - _createRegisteredTool( - name: string, - title: string | undefined, - description: string | undefined, - inputSchema: ZodRawShapeCompat | AnySchema | undefined, - outputSchema: ZodRawShapeCompat | AnySchema | undefined, - annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, - _meta: Record | undefined, - handler: AnyToolHandler - ): RegisteredTool; -} - -/** - * Experimental task features for 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 `createTask`, `getTask`, and `getTaskResult` - * methods. - * - * @example - * ```typescript - * server.experimental.tasks.registerToolTask('long-computation', { - * description: 'Performs a long computation', - * inputSchema: { input: z.string() }, - * execution: { taskSupport: 'required' } - * }, { - * createTask: async (args, extra) => { - * const task = await extra.taskStore.createTask({ ttl: 300000 }); - * startBackgroundWork(task.taskId, args); - * return { task }; - * }, - * getTask: async (args, extra) => { - * return extra.taskStore.getTask(extra.taskId); - * }, - * getTaskResult: async (args, extra) => { - * return extra.taskStore.getTaskResult(extra.taskId); - * } - * }); - * ``` - * - * @param name - The tool name - * @param config - Tool configuration (description, schemas, etc.) - * @param handler - Task handler with createTask, getTask, getTaskResult methods - * @returns 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< - InputArgs extends undefined | ZodRawShapeCompat | AnySchema, - OutputArgs extends undefined | ZodRawShapeCompat | AnySchema - >( - 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/src/experimental/tasks/server.ts b/src/experimental/tasks/server.ts deleted file mode 100644 index a4150a8d7..000000000 --- a/src/experimental/tasks/server.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Experimental server task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import type { Server } from '../../server/index.js'; -import type { RequestOptions } from '../../shared/protocol.js'; -import type { ResponseMessage } from '../../shared/responseMessage.js'; -import type { AnySchema, SchemaOutput } from '../../server/zod-compat.js'; -import type { ServerRequest, Notification, Request, Result, GetTaskResult, ListTasksResult, CancelTaskResult } from '../../types.js'; - -/** - * Experimental task features for low-level MCP servers. - * - * Access via `server.experimental.tasks`: - * ```typescript - * const stream = server.experimental.tasks.requestStream(request, schema, options); - * ``` - * - * For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead. - * - * @experimental - */ -export class ExperimentalServerTasks< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> { - constructor(private readonly _server: Server) {} - - /** - * 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 - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - requestStream( - request: ServerRequest | RequestT, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void> { - // Delegate to the server's underlying Protocol method - type ServerWithRequestStream = { - requestStream( - request: ServerRequest | RequestT, - resultSchema: U, - options?: RequestOptions - ): AsyncGenerator>, void, void>; - }; - return (this._server as unknown as ServerWithRequestStream).requestStream(request, resultSchema, options); - } - - /** - * 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 { - type ServerWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise }; - return (this._server as unknown as ServerWithGetTask).getTask({ taskId }, options); - } - - /** - * Retrieves the result of a completed task. - * - * @param taskId - The task identifier - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options - * @returns The task result - * - * @experimental - */ - async getTaskResult(taskId: string, resultSchema?: T, options?: RequestOptions): Promise> { - return ( - this._server as unknown as { - getTaskResult: ( - params: { taskId: string }, - resultSchema?: U, - options?: RequestOptions - ) => Promise>; - } - ).getTaskResult({ taskId }, resultSchema, 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._server as unknown as { - listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise; - } - ).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._server as unknown as { - cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise; - } - ).cancelTask({ taskId }, options); - } -} diff --git a/src/experimental/tasks/stores/in-memory.ts b/src/experimental/tasks/stores/in-memory.ts deleted file mode 100644 index aff3ad910..000000000 --- a/src/experimental/tasks/stores/in-memory.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * In-memory implementations of TaskStore and TaskMessageQueue. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ - -import { Task, RequestId, Result, Request } from '../../../types.js'; -import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; -import { randomBytes } from 'node:crypto'; - -interface StoredTask { - task: Task; - request: Request; - requestId: RequestId; - result?: Result; -} - -/** - * A simple in-memory implementation of TaskStore for demonstration purposes. - * - * This implementation stores all tasks in memory and provides automatic cleanup - * based on the ttl duration specified in the task creation parameters. - * - * Note: This is not suitable for production use as all data is lost on restart. - * For production, consider implementing TaskStore with a database or distributed cache. - * - * @experimental - */ -export class InMemoryTaskStore implements TaskStore { - private tasks = new Map(); - private cleanupTimers = new Map>(); - - /** - * Generates a unique task ID. - * Uses 16 bytes of random data encoded as hex (32 characters). - */ - private generateTaskId(): string { - return randomBytes(16).toString('hex'); - } - - 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 - }); - - // 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; - } - - async getTask(taskId: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - return stored ? { ...stored.task } : null; - } - - async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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); - } - } - - async getTaskResult(taskId: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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; - } - - async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, _sessionId?: string): Promise { - const stored = this.tasks.get(taskId); - 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); - } - } - - async listTasks(cursor?: string, _sessionId?: string): Promise<{ tasks: Task[]; nextCursor?: string }> { - const PAGE_SIZE = 10; - const allTaskIds = Array.from(this.tasks.keys()); - - let startIndex = 0; - if (cursor) { - const cursorIndex = allTaskIds.indexOf(cursor); - if (cursorIndex >= 0) { - startIndex = cursorIndex + 1; - } else { - // Invalid cursor - throw error - throw new Error(`Invalid cursor: ${cursor}`); - } - } - - const pageTaskIds = allTaskIds.slice(startIndex, startIndex + PAGE_SIZE); - const tasks = pageTaskIds.map(taskId => { - const stored = this.tasks.get(taskId)!; - return { ...stored.task }; - }); - - const nextCursor = startIndex + PAGE_SIZE < allTaskIds.length ? pageTaskIds[pageTaskIds.length - 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 Array.from(this.tasks.values()).map(stored => ({ ...stored.task })); - } -} - -/** - * A simple in-memory implementation of TaskMessageQueue for demonstration purposes. - * - * This implementation stores messages in memory, organized by task ID and optional session ID. - * Messages are stored in FIFO queues per task. - * - * Note: This is not suitable for production use in distributed systems. - * For production, consider implementing TaskMessageQueue with Redis or other distributed queues. - * - * @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/src/experimental/tasks/types.ts b/src/experimental/tasks/types.ts deleted file mode 100644 index a3845bae1..000000000 --- a/src/experimental/tasks/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Re-exports of task-related types from the MCP protocol spec. - * WARNING: These APIs are experimental and may change without notice. - * - * These types are defined in types.ts (matching the protocol spec) and - * re-exported here for convenience when working with experimental task features. - */ - -// Task schemas (Zod) -export { - TaskCreationParamsSchema, - RelatedTaskMetadataSchema, - TaskSchema, - CreateTaskResultSchema, - TaskStatusNotificationParamsSchema, - TaskStatusNotificationSchema, - GetTaskRequestSchema, - GetTaskResultSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - ListTasksResultSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, - ClientTasksCapabilitySchema, - ServerTasksCapabilitySchema -} from '../../types.js'; - -// Task types (inferred from schemas) -export type { - Task, - TaskCreationParams, - RelatedTaskMetadata, - CreateTaskResult, - TaskStatusNotificationParams, - TaskStatusNotification, - GetTaskRequest, - GetTaskResult, - GetTaskPayloadRequest, - ListTasksRequest, - ListTasksResult, - CancelTaskRequest, - CancelTaskResult -} from '../../types.js'; diff --git a/src/inMemory.ts b/src/inMemory.ts deleted file mode 100644 index 26062624d..000000000 --- a/src/inMemory.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Transport } from './shared/transport.js'; -import { JSONRPCMessage, RequestId } from './types.js'; -import { AuthInfo } from './server/auth/types.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. - */ -export class InMemoryTransport implements Transport { - private _otherTransport?: InMemoryTransport; - private _messageQueue: QueuedMessage[] = []; - - 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 Client and one to a 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 { - const other = this._otherTransport; - this._otherTransport = undefined; - await other?.close(); - 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 Error('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/src/server/auth/clients.ts b/src/server/auth/clients.ts deleted file mode 100644 index 4e3f8e17e..000000000 --- a/src/server/auth/clients.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { OAuthClientInformationFull } from '../../shared/auth.js'; - -/** - * Stores information about registered OAuth clients for this server. - */ -export interface OAuthRegisteredClientsStore { - /** - * Returns information about a registered client, based on its ID. - */ - getClient(clientId: string): OAuthClientInformationFull | undefined | Promise; - - /** - * Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server. - * - * NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well. - * - * If unimplemented, dynamic client registration is unsupported. - */ - registerClient?( - client: Omit - ): OAuthClientInformationFull | Promise; -} diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts deleted file mode 100644 index dff413e38..000000000 --- a/src/server/auth/errors.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { OAuthErrorResponse } from '../../shared/auth.js'; - -/** - * Base class for all OAuth errors - */ -export class OAuthError extends Error { - static errorCode: string; - - constructor( - message: string, - public readonly errorUri?: string - ) { - super(message); - this.name = this.constructor.name; - } - - /** - * Converts the error to a standard OAuth error response object - */ - toResponseObject(): OAuthErrorResponse { - const response: OAuthErrorResponse = { - error: this.errorCode, - error_description: this.message - }; - - if (this.errorUri) { - response.error_uri = this.errorUri; - } - - return response; - } - - get errorCode(): string { - return (this.constructor as typeof OAuthError).errorCode; - } -} - -/** - * Invalid request error - The request is missing a required parameter, - * includes an invalid parameter value, includes a parameter more than once, - * or is otherwise malformed. - */ -export class InvalidRequestError extends OAuthError { - static errorCode = 'invalid_request'; -} - -/** - * Invalid client error - Client authentication failed (e.g., unknown client, no client - * authentication included, or unsupported authentication method). - */ -export class InvalidClientError extends OAuthError { - static errorCode = 'invalid_client'; -} - -/** - * Invalid grant error - 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. - */ -export class InvalidGrantError extends OAuthError { - static errorCode = 'invalid_grant'; -} - -/** - * Unauthorized client error - The authenticated client is not authorized to use - * this authorization grant type. - */ -export class UnauthorizedClientError extends OAuthError { - static errorCode = 'unauthorized_client'; -} - -/** - * Unsupported grant type error - The authorization grant type is not supported - * by the authorization server. - */ -export class UnsupportedGrantTypeError extends OAuthError { - static errorCode = 'unsupported_grant_type'; -} - -/** - * Invalid scope error - The requested scope is invalid, unknown, malformed, or - * exceeds the scope granted by the resource owner. - */ -export class InvalidScopeError extends OAuthError { - static errorCode = 'invalid_scope'; -} - -/** - * Access denied error - The resource owner or authorization server denied the request. - */ -export class AccessDeniedError extends OAuthError { - static errorCode = 'access_denied'; -} - -/** - * Server error - The authorization server encountered an unexpected condition - * that prevented it from fulfilling the request. - */ -export class ServerError extends OAuthError { - static errorCode = 'server_error'; -} - -/** - * Temporarily unavailable error - The authorization server is currently unable to - * handle the request due to a temporary overloading or maintenance of the server. - */ -export class TemporarilyUnavailableError extends OAuthError { - static errorCode = 'temporarily_unavailable'; -} - -/** - * Unsupported response type error - The authorization server does not support - * obtaining an authorization code using this method. - */ -export class UnsupportedResponseTypeError extends OAuthError { - static errorCode = 'unsupported_response_type'; -} - -/** - * Unsupported token type error - The authorization server does not support - * the requested token type. - */ -export class UnsupportedTokenTypeError extends OAuthError { - static errorCode = 'unsupported_token_type'; -} - -/** - * Invalid token error - The access token provided is expired, revoked, malformed, - * or invalid for other reasons. - */ -export class InvalidTokenError extends OAuthError { - static errorCode = 'invalid_token'; -} - -/** - * Method not allowed error - The HTTP method used is not allowed for this endpoint. - * (Custom, non-standard error) - */ -export class MethodNotAllowedError extends OAuthError { - static errorCode = 'method_not_allowed'; -} - -/** - * Too many requests error - Rate limit exceeded. - * (Custom, non-standard error based on RFC 6585) - */ -export class TooManyRequestsError extends OAuthError { - static errorCode = 'too_many_requests'; -} - -/** - * Invalid client metadata error - The client metadata is invalid. - * (Custom error for dynamic client registration - RFC 7591) - */ -export class InvalidClientMetadataError extends OAuthError { - static errorCode = 'invalid_client_metadata'; -} - -/** - * Insufficient scope error - The request requires higher privileges than provided by the access token. - */ -export class InsufficientScopeError extends OAuthError { - static errorCode = 'insufficient_scope'; -} - -/** - * Invalid target error - The requested resource is invalid, missing, unknown, or malformed. - * (Custom error for resource indicators - RFC 8707) - */ -export class InvalidTargetError extends OAuthError { - static errorCode = 'invalid_target'; -} - -/** - * A utility class for defining one-off error codes - */ -export class CustomOAuthError extends OAuthError { - constructor( - private readonly customErrorCode: string, - message: string, - errorUri?: string - ) { - super(message, errorUri); - } - - get errorCode(): string { - return this.customErrorCode; - } -} - -/** - * A full list of all OAuthErrors, enabling parsing from error responses - */ -export const OAUTH_ERRORS = { - [InvalidRequestError.errorCode]: InvalidRequestError, - [InvalidClientError.errorCode]: InvalidClientError, - [InvalidGrantError.errorCode]: InvalidGrantError, - [UnauthorizedClientError.errorCode]: UnauthorizedClientError, - [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, - [InvalidScopeError.errorCode]: InvalidScopeError, - [AccessDeniedError.errorCode]: AccessDeniedError, - [ServerError.errorCode]: ServerError, - [TemporarilyUnavailableError.errorCode]: TemporarilyUnavailableError, - [UnsupportedResponseTypeError.errorCode]: UnsupportedResponseTypeError, - [UnsupportedTokenTypeError.errorCode]: UnsupportedTokenTypeError, - [InvalidTokenError.errorCode]: InvalidTokenError, - [MethodNotAllowedError.errorCode]: MethodNotAllowedError, - [TooManyRequestsError.errorCode]: TooManyRequestsError, - [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, - [InsufficientScopeError.errorCode]: InsufficientScopeError, - [InvalidTargetError.errorCode]: InvalidTargetError -} as const; diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts deleted file mode 100644 index dcb6c03ec..000000000 --- a/src/server/auth/handlers/authorize.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { RequestHandler } from 'express'; -import * as z from 'zod/v4'; -import express from 'express'; -import { OAuthServerProvider } from '../provider.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; - -export type AuthorizationHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the authorization endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -// Parameters that must be validated in order to issue redirects. -const ClientAuthorizationParamsSchema = z.object({ - client_id: z.string(), - redirect_uri: z - .string() - .optional() - .refine(value => value === undefined || URL.canParse(value), { message: 'redirect_uri must be a valid URL' }) -}); - -// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI. -const RequestAuthorizationParamsSchema = z.object({ - response_type: z.literal('code'), - code_challenge: z.string(), - code_challenge_method: z.literal('S256'), - scope: z.string().optional(), - state: z.string().optional(), - resource: z.string().url().optional() -}); - -export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { - // Create a router to apply middleware - const router = express.Router(); - router.use(allowedMethods(['GET', 'POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // 100 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - router.all('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - // In the authorization flow, errors are split into two categories: - // 1. Pre-redirect errors (direct response with 400) - // 2. Post-redirect errors (redirect with error parameters) - - // Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses. - let client_id, redirect_uri, client; - try { - const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); - if (!result.success) { - throw new InvalidRequestError(result.error.message); - } - - client_id = result.data.client_id; - redirect_uri = result.data.redirect_uri; - - client = await provider.clientsStore.getClient(client_id); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - - if (redirect_uri !== undefined) { - if (!client.redirect_uris.includes(redirect_uri)) { - throw new InvalidRequestError('Unregistered redirect_uri'); - } - } else if (client.redirect_uris.length === 1) { - redirect_uri = client.redirect_uris[0]; - } else { - throw new InvalidRequestError('redirect_uri must be specified when client has multiple registered URIs'); - } - } catch (error) { - // Pre-redirect errors - return direct response - // - // These don't need to be JSON encoded, as they'll be displayed in a user - // agent, but OTOH they all represent exceptional situations (arguably, - // "programmer error"), so presenting a nice HTML page doesn't help the - // user anyway. - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - - return; - } - - // Phase 2: Validate other parameters. Any errors here should go into redirect responses. - let state; - try { - // Parse and validate authorization parameters - const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { scope, code_challenge, resource } = parseResult.data; - state = parseResult.data.state; - - // Validate scopes - let requestedScopes: string[] = []; - if (scope !== undefined) { - requestedScopes = scope.split(' '); - } - - // All validation passed, proceed with authorization - await provider.authorize( - client, - { - state, - scopes: requestedScopes, - redirectUri: redirect_uri, - codeChallenge: code_challenge, - resource: resource ? new URL(resource) : undefined - }, - res - ); - } catch (error) { - // Post-redirect errors - redirect with error parameters - if (error instanceof OAuthError) { - res.redirect(302, createErrorRedirect(redirect_uri, error, state)); - } else { - const serverError = new ServerError('Internal Server Error'); - res.redirect(302, createErrorRedirect(redirect_uri, serverError, state)); - } - } - }); - - return router; -} - -/** - * Helper function to create redirect URL with error parameters - */ -function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string { - const errorUrl = new URL(redirectUri); - errorUrl.searchParams.set('error', error.errorCode); - errorUrl.searchParams.set('error_description', error.message); - if (error.errorUri) { - errorUrl.searchParams.set('error_uri', error.errorUri); - } - if (state) { - errorUrl.searchParams.set('state', state); - } - return errorUrl.href; -} diff --git a/src/server/auth/handlers/metadata.ts b/src/server/auth/handlers/metadata.ts deleted file mode 100644 index e0f07a99b..000000000 --- a/src/server/auth/handlers/metadata.ts +++ /dev/null @@ -1,19 +0,0 @@ -import express, { RequestHandler } from 'express'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../shared/auth.js'; -import cors from 'cors'; -import { allowedMethods } from '../middleware/allowedMethods.js'; - -export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['GET', 'OPTIONS'])); - router.get('/', (req, res) => { - res.status(200).json(metadata); - }); - - return router; -} diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts deleted file mode 100644 index 1830619b4..000000000 --- a/src/server/auth/handlers/register.ts +++ /dev/null @@ -1,119 +0,0 @@ -import express, { RequestHandler } from 'express'; -import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../shared/auth.js'; -import crypto from 'node:crypto'; -import cors from 'cors'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; - -export type ClientRegistrationHandlerOptions = { - /** - * A store used to save information about dynamically registered OAuth clients. - */ - clientsStore: OAuthRegisteredClientsStore; - - /** - * The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended). - * - * If not set, defaults to 30 days. - */ - clientSecretExpirySeconds?: number; - - /** - * Rate limiting configuration for the client registration endpoint. - * Set to false to disable rate limiting for this endpoint. - * Registration endpoints are particularly sensitive to abuse and should be rate limited. - */ - rateLimit?: Partial | false; - - /** - * Whether to generate a client ID before calling the client registration endpoint. - * - * If not set, defaults to true. - */ - clientIdGeneration?: boolean; -}; - -const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days - -export function clientRegistrationHandler({ - clientsStore, - clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS, - rateLimit: rateLimitConfig, - clientIdGeneration = true -}: ClientRegistrationHandlerOptions): RequestHandler { - if (!clientsStore.registerClient) { - throw new Error('Client registration store does not support registering clients'); - } - - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.json()); - - // Apply rate limiting unless explicitly disabled - stricter limits for registration - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 20, // 20 requests per hour - stricter as registration is sensitive - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = OAuthClientMetadataSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidClientMetadataError(parseResult.error.message); - } - - const clientMetadata = parseResult.data; - const isPublicClient = clientMetadata.token_endpoint_auth_method === 'none'; - - // Generate client credentials - const clientSecret = isPublicClient ? undefined : crypto.randomBytes(32).toString('hex'); - const clientIdIssuedAt = Math.floor(Date.now() / 1000); - - // Calculate client secret expiry time - const clientsDoExpire = clientSecretExpirySeconds > 0; - const secretExpiryTime = clientsDoExpire ? clientIdIssuedAt + clientSecretExpirySeconds : 0; - const clientSecretExpiresAt = isPublicClient ? undefined : secretExpiryTime; - - let clientInfo: Omit & { client_id?: string } = { - ...clientMetadata, - client_secret: clientSecret, - client_secret_expires_at: clientSecretExpiresAt - }; - - if (clientIdGeneration) { - clientInfo.client_id = crypto.randomUUID(); - clientInfo.client_id_issued_at = clientIdIssuedAt; - } - - clientInfo = await clientsStore.registerClient!(clientInfo); - res.status(201).json(clientInfo); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/src/server/auth/handlers/revoke.ts b/src/server/auth/handlers/revoke.ts deleted file mode 100644 index da7ef04f8..000000000 --- a/src/server/auth/handlers/revoke.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { OAuthServerProvider } from '../provider.js'; -import express, { RequestHandler } from 'express'; -import cors from 'cors'; -import { authenticateClient } from '../middleware/clientAuth.js'; -import { OAuthTokenRevocationRequestSchema } from '../../../shared/auth.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../errors.js'; - -export type RevocationHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the token revocation endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler { - if (!provider.revokeToken) { - throw new Error('Auth provider does not support revoking tokens'); - } - - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const client = req.client; - if (!client) { - // This should never happen - throw new ServerError('Internal Server Error'); - } - - await provider.revokeToken!(client, parseResult.data); - res.status(200).json({}); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts deleted file mode 100644 index 4cc4e8ab8..000000000 --- a/src/server/auth/handlers/token.ts +++ /dev/null @@ -1,155 +0,0 @@ -import * as z from 'zod/v4'; -import express, { RequestHandler } from 'express'; -import { OAuthServerProvider } from '../provider.js'; -import cors from 'cors'; -import { verifyChallenge } from 'pkce-challenge'; -import { authenticateClient } from '../middleware/clientAuth.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; -import { allowedMethods } from '../middleware/allowedMethods.js'; -import { - InvalidRequestError, - InvalidGrantError, - UnsupportedGrantTypeError, - ServerError, - TooManyRequestsError, - OAuthError -} from '../errors.js'; - -export type TokenHandlerOptions = { - provider: OAuthServerProvider; - /** - * Rate limiting configuration for the token endpoint. - * Set to false to disable rate limiting for this endpoint. - */ - rateLimit?: Partial | false; -}; - -const TokenRequestSchema = z.object({ - grant_type: z.string() -}); - -const AuthorizationCodeGrantSchema = z.object({ - code: z.string(), - code_verifier: z.string(), - redirect_uri: z.string().optional(), - resource: z.string().url().optional() -}); - -const RefreshTokenGrantSchema = z.object({ - refresh_token: z.string(), - scope: z.string().optional(), - resource: z.string().url().optional() -}); - -export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { - // Nested router so we can configure middleware and restrict HTTP method - const router = express.Router(); - - // Configure CORS to allow any origin, to make accessible to web-based MCP clients - router.use(cors()); - - router.use(allowedMethods(['POST'])); - router.use(express.urlencoded({ extended: false })); - - // Apply rate limiting unless explicitly disabled - if (rateLimitConfig !== false) { - router.use( - rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 50, // 50 requests per windowMs - standardHeaders: true, - legacyHeaders: false, - message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(), - ...rateLimitConfig - }) - ); - } - - // Authenticate and extract client details - router.use(authenticateClient({ clientsStore: provider.clientsStore })); - - router.post('/', async (req, res) => { - res.setHeader('Cache-Control', 'no-store'); - - try { - const parseResult = TokenRequestSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { grant_type } = parseResult.data; - - const client = req.client; - if (!client) { - // This should never happen - throw new ServerError('Internal Server Error'); - } - - switch (grant_type) { - case 'authorization_code': { - const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { code, code_verifier, redirect_uri, resource } = parseResult.data; - - const skipLocalPkceValidation = provider.skipLocalPkceValidation; - - // Perform local PKCE validation unless explicitly skipped - // (e.g. to validate code_verifier in upstream server) - if (!skipLocalPkceValidation) { - const codeChallenge = await provider.challengeForAuthorizationCode(client, code); - if (!(await verifyChallenge(code_verifier, codeChallenge))) { - throw new InvalidGrantError('code_verifier does not match the challenge'); - } - } - - // Passes the code_verifier to the provider if PKCE validation didn't occur locally - const tokens = await provider.exchangeAuthorizationCode( - client, - code, - skipLocalPkceValidation ? code_verifier : undefined, - redirect_uri, - resource ? new URL(resource) : undefined - ); - res.status(200).json(tokens); - break; - } - - case 'refresh_token': { - const parseResult = RefreshTokenGrantSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - - const { refresh_token, scope, resource } = parseResult.data; - - const scopes = scope?.split(' '); - const tokens = await provider.exchangeRefreshToken( - client, - refresh_token, - scopes, - resource ? new URL(resource) : undefined - ); - res.status(200).json(tokens); - break; - } - // Additional auth methods will not be added on the server side of the SDK. - case 'client_credentials': - default: - throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); - } - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }); - - return router; -} diff --git a/src/server/auth/middleware/allowedMethods.ts b/src/server/auth/middleware/allowedMethods.ts deleted file mode 100644 index 74633aa57..000000000 --- a/src/server/auth/middleware/allowedMethods.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { RequestHandler } from 'express'; -import { MethodNotAllowedError } from '../errors.js'; - -/** - * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. - * - * @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST']) - * @returns Express middleware that returns a 405 error if method not in allowed list - */ -export function allowedMethods(allowedMethods: string[]): RequestHandler { - return (req, res, next) => { - if (allowedMethods.includes(req.method)) { - next(); - return; - } - - const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`); - res.status(405).set('Allow', allowedMethods.join(', ')).json(error.toResponseObject()); - }; -} diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts deleted file mode 100644 index dac653086..000000000 --- a/src/server/auth/middleware/bearerAuth.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { RequestHandler } from 'express'; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../errors.js'; -import { OAuthTokenVerifier } from '../provider.js'; -import { AuthInfo } from '../types.js'; - -export type BearerAuthMiddlewareOptions = { - /** - * A provider used to verify tokens. - */ - verifier: OAuthTokenVerifier; - - /** - * Optional scopes that the token must have. - */ - requiredScopes?: string[]; - - /** - * Optional resource metadata URL to include in WWW-Authenticate header. - */ - resourceMetadataUrl?: string; -}; - -declare module 'express-serve-static-core' { - interface Request { - /** - * Information about the validated access token, if the `requireBearerAuth` middleware was used. - */ - auth?: AuthInfo; - } -} - -/** - * Middleware that requires a valid Bearer token in the Authorization header. - * - * This will validate the token with the auth provider and add the resulting auth info to the request object. - * - * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header - * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. - */ -export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { - return async (req, res, next) => { - try { - const authHeader = req.headers.authorization; - if (!authHeader) { - throw new InvalidTokenError('Missing Authorization header'); - } - - const [type, token] = authHeader.split(' '); - if (type.toLowerCase() !== 'bearer' || !token) { - throw new InvalidTokenError("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 InsufficientScopeError('Insufficient scope'); - } - } - - // Check if the token is set to expire or if it is expired - if (typeof authInfo.expiresAt !== 'number' || isNaN(authInfo.expiresAt)) { - throw new InvalidTokenError('Token has no expiration time'); - } else if (authInfo.expiresAt < Date.now() / 1000) { - throw new InvalidTokenError('Token has expired'); - } - - req.auth = authInfo; - next(); - } catch (error) { - // Build WWW-Authenticate header parts - const buildWwwAuthHeader = (errorCode: string, message: string): string => { - let header = `Bearer error="${errorCode}", error_description="${message}"`; - if (requiredScopes.length > 0) { - header += `, scope="${requiredScopes.join(' ')}"`; - } - if (resourceMetadataUrl) { - header += `, resource_metadata="${resourceMetadataUrl}"`; - } - return header; - }; - - if (error instanceof InvalidTokenError) { - res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); - res.status(401).json(error.toResponseObject()); - } else if (error instanceof InsufficientScopeError) { - res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); - res.status(403).json(error.toResponseObject()); - } else if (error instanceof ServerError) { - res.status(500).json(error.toResponseObject()); - } else if (error instanceof OAuthError) { - res.status(400).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }; -} diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts deleted file mode 100644 index 6cc6a1923..000000000 --- a/src/server/auth/middleware/clientAuth.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as z from 'zod/v4'; -import { RequestHandler } from 'express'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { OAuthClientInformationFull } from '../../../shared/auth.js'; -import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; - -export type ClientAuthenticationMiddlewareOptions = { - /** - * A store used to read information about registered OAuth clients. - */ - clientsStore: OAuthRegisteredClientsStore; -}; - -const ClientAuthenticatedRequestSchema = z.object({ - client_id: z.string(), - client_secret: z.string().optional() -}); - -declare module 'express-serve-static-core' { - interface Request { - /** - * The authenticated client for this request, if the `authenticateClient` middleware was used. - */ - client?: OAuthClientInformationFull; - } -} - -export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { - return async (req, res, next) => { - try { - const result = ClientAuthenticatedRequestSchema.safeParse(req.body); - if (!result.success) { - throw new InvalidRequestError(String(result.error)); - } - const { client_id, client_secret } = result.data; - const client = await clientsStore.getClient(client_id); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - if (client.client_secret) { - if (!client_secret) { - throw new InvalidClientError('Client secret is required'); - } - if (client.client_secret !== client_secret) { - throw new InvalidClientError('Invalid client_secret'); - } - if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { - throw new InvalidClientError('Client secret has expired'); - } - } - - req.client = client; - next(); - } catch (error) { - if (error instanceof OAuthError) { - const status = error instanceof ServerError ? 500 : 400; - res.status(status).json(error.toResponseObject()); - } else { - const serverError = new ServerError('Internal Server Error'); - res.status(500).json(serverError.toResponseObject()); - } - } - }; -} diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts deleted file mode 100644 index cf1c306de..000000000 --- a/src/server/auth/provider.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Response } from 'express'; -import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; -import { AuthInfo } from './types.js'; - -export type AuthorizationParams = { - state?: string; - scopes?: string[]; - codeChallenge: string; - redirectUri: string; - resource?: URL; -}; - -/** - * Implements an end-to-end OAuth server. - */ -export interface OAuthServerProvider { - /** - * A store used to read information about registered OAuth clients. - */ - get clientsStore(): OAuthRegisteredClientsStore; - - /** - * Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server. - * - * This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1: - * - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters. - * - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter. - */ - authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise; - - /** - * Returns the `codeChallenge` that was used when the indicated authorization began. - */ - challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise; - - /** - * Exchanges an authorization code for an access token. - */ - exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - codeVerifier?: string, - redirectUri?: string, - resource?: URL - ): Promise; - - /** - * Exchanges a refresh token for an access token. - */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; - - /** - * Verifies an access token and returns information about it. - */ - verifyAccessToken(token: string): Promise; - - /** - * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). - * - * If the given token is invalid or already revoked, this method should do nothing. - */ - revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise; - - /** - * Whether to skip local PKCE validation. - * - * If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server. - * - * NOTE: This should only be true if the upstream server is performing the actual PKCE validation. - */ - skipLocalPkceValidation?: boolean; -} - -/** - * Slim implementation useful for token verification - */ -export interface OAuthTokenVerifier { - /** - * Verifies an access token and returns information about it. - */ - verifyAccessToken(token: string): Promise; -} diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts deleted file mode 100644 index 855856c89..000000000 --- a/src/server/auth/providers/proxyProvider.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Response } from 'express'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { - OAuthClientInformationFull, - OAuthClientInformationFullSchema, - OAuthTokenRevocationRequest, - OAuthTokens, - OAuthTokensSchema -} from '../../../shared/auth.js'; -import { AuthInfo } from '../types.js'; -import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; -import { ServerError } from '../errors.js'; -import { FetchLike } from '../../../shared/transport.js'; - -export type ProxyEndpoints = { - authorizationUrl: string; - tokenUrl: string; - revocationUrl?: string; - registrationUrl?: string; -}; - -export type ProxyOptions = { - /** - * Individual endpoint URLs for proxying specific OAuth operations - */ - endpoints: ProxyEndpoints; - - /** - * Function to verify access tokens and return auth info - */ - verifyAccessToken: (token: string) => Promise; - - /** - * Function to fetch client information from the upstream server - */ - getClient: (clientId: string) => Promise; - - /** - * Custom fetch implementation used for all network requests. - */ - fetch?: FetchLike; -}; - -/** - * Implements an OAuth server that proxies requests to another OAuth server. - */ -export class ProxyOAuthServerProvider implements OAuthServerProvider { - protected readonly _endpoints: ProxyEndpoints; - protected readonly _verifyAccessToken: (token: string) => Promise; - protected readonly _getClient: (clientId: string) => Promise; - protected readonly _fetch?: FetchLike; - - skipLocalPkceValidation = true; - - revokeToken?: (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => Promise; - - constructor(options: ProxyOptions) { - this._endpoints = options.endpoints; - this._verifyAccessToken = options.verifyAccessToken; - this._getClient = options.getClient; - this._fetch = options.fetch; - if (options.endpoints?.revocationUrl) { - this.revokeToken = async (client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest) => { - const revocationUrl = this._endpoints.revocationUrl; - - if (!revocationUrl) { - throw new Error('No revocation endpoint configured'); - } - - const params = new URLSearchParams(); - params.set('token', request.token); - params.set('client_id', client.client_id); - if (client.client_secret) { - params.set('client_secret', client.client_secret); - } - if (request.token_type_hint) { - params.set('token_type_hint', request.token_type_hint); - } - - const response = await (this._fetch ?? fetch)(revocationUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - await response.body?.cancel(); - - if (!response.ok) { - throw new ServerError(`Token revocation failed: ${response.status}`); - } - }; - } - } - - get clientsStore(): OAuthRegisteredClientsStore { - const registrationUrl = this._endpoints.registrationUrl; - return { - getClient: this._getClient, - ...(registrationUrl && { - registerClient: async (client: OAuthClientInformationFull) => { - const response = await (this._fetch ?? fetch)(registrationUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(client) - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Client registration failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthClientInformationFullSchema.parse(data); - } - }) - }; - } - - async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - // Start with required OAuth parameters - const targetUrl = new URL(this._endpoints.authorizationUrl); - const searchParams = new URLSearchParams({ - client_id: client.client_id, - response_type: 'code', - redirect_uri: params.redirectUri, - code_challenge: params.codeChallenge, - code_challenge_method: 'S256' - }); - - // Add optional standard OAuth parameters - if (params.state) searchParams.set('state', params.state); - if (params.scopes?.length) searchParams.set('scope', params.scopes.join(' ')); - if (params.resource) searchParams.set('resource', params.resource.href); - - targetUrl.search = searchParams.toString(); - res.redirect(targetUrl.toString()); - } - - async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _authorizationCode: string): Promise { - // In a proxy setup, we don't store the code challenge ourselves - // Instead, we proxy the token request and let the upstream server validate it - return ''; - } - - async exchangeAuthorizationCode( - client: OAuthClientInformationFull, - authorizationCode: string, - codeVerifier?: string, - redirectUri?: string, - resource?: URL - ): Promise { - const params = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: client.client_id, - code: authorizationCode - }); - - if (client.client_secret) { - params.append('client_secret', client.client_secret); - } - - if (codeVerifier) { - params.append('code_verifier', codeVerifier); - } - - if (redirectUri) { - params.append('redirect_uri', redirectUri); - } - - if (resource) { - params.append('resource', resource.href); - } - - const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Token exchange failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthTokensSchema.parse(data); - } - - async exchangeRefreshToken( - client: OAuthClientInformationFull, - refreshToken: string, - scopes?: string[], - resource?: URL - ): Promise { - const params = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: client.client_id, - refresh_token: refreshToken - }); - - if (client.client_secret) { - params.set('client_secret', client.client_secret); - } - - if (scopes?.length) { - params.set('scope', scopes.join(' ')); - } - - if (resource) { - params.set('resource', resource.href); - } - - const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new ServerError(`Token refresh failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthTokensSchema.parse(data); - } - - async verifyAccessToken(token: string): Promise { - return this._verifyAccessToken(token); - } -} diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts deleted file mode 100644 index 1df0be091..000000000 --- a/src/server/auth/router.ts +++ /dev/null @@ -1,240 +0,0 @@ -import express, { RequestHandler } from 'express'; -import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './handlers/register.js'; -import { tokenHandler, TokenHandlerOptions } from './handlers/token.js'; -import { authorizationHandler, AuthorizationHandlerOptions } from './handlers/authorize.js'; -import { revocationHandler, RevocationHandlerOptions } from './handlers/revoke.js'; -import { metadataHandler } from './handlers/metadata.js'; -import { OAuthServerProvider } from './provider.js'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../shared/auth.js'; - -// Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) -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.'); -} - -export type AuthRouterOptions = { - /** - * A provider implementing the actual authorization logic for this router. - */ - provider: OAuthServerProvider; - - /** - * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. - */ - issuerUrl: URL; - - /** - * The base URL of the authorization server to use for the metadata endpoints. - * - * If not provided, the issuer URL will be used as the base URL. - */ - baseUrl?: URL; - - /** - * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. - */ - serviceDocumentationUrl?: URL; - - /** - * An optional list of scopes supported by this authorization server - */ - scopesSupported?: string[]; - - /** - * The resource name to be displayed in protected resource metadata - */ - resourceName?: string; - - /** - * The URL of the protected resource (RS) whose metadata we advertise. - * If not provided, falls back to `baseUrl` and then to `issuerUrl` (AS=RS). - */ - resourceServerUrl?: URL; - - // Individual options per route - authorizationOptions?: Omit; - clientRegistrationOptions?: Omit; - revocationOptions?: Omit; - tokenOptions?: Omit; -}; - -const checkIssuerUrl = (issuer: URL): void => { - // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of 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}`); - } -}; - -export const createOAuthMetadata = (options: { - provider: OAuthServerProvider; - issuerUrl: URL; - baseUrl?: URL; - serviceDocumentationUrl?: URL; - scopesSupported?: string[]; -}): OAuthMetadata => { - const issuer = options.issuerUrl; - const baseUrl = options.baseUrl; - - checkIssuerUrl(issuer); - - const authorization_endpoint = '/authorize'; - const token_endpoint = '/token'; - const registration_endpoint = options.provider.clientsStore.registerClient ? '/register' : undefined; - const revocation_endpoint = options.provider.revokeToken ? '/revoke' : undefined; - - const metadata: OAuthMetadata = { - issuer: issuer.href, - service_documentation: options.serviceDocumentationUrl?.href, - - authorization_endpoint: new URL(authorization_endpoint, baseUrl || issuer).href, - response_types_supported: ['code'], - code_challenge_methods_supported: ['S256'], - - token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, - token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], - grant_types_supported: ['authorization_code', 'refresh_token'], - - scopes_supported: options.scopesSupported, - - revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined, - revocation_endpoint_auth_methods_supported: revocation_endpoint ? ['client_secret_post'] : undefined, - - registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined - }; - - return metadata; -}; - -/** - * Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported). - * Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. - * Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead. - * - * By default, rate limiting is applied to all endpoints to prevent abuse. - * - * This router MUST be installed at the application root, like so: - * - * const app = express(); - * app.use(mcpAuthRouter(...)); - */ -export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { - const oauthMetadata = createOAuthMetadata(options); - - const router = express.Router(); - - router.use( - new URL(oauthMetadata.authorization_endpoint).pathname, - authorizationHandler({ provider: options.provider, ...options.authorizationOptions }) - ); - - router.use(new URL(oauthMetadata.token_endpoint).pathname, tokenHandler({ provider: options.provider, ...options.tokenOptions })); - - router.use( - mcpAuthMetadataRouter({ - oauthMetadata, - // Prefer explicit RS; otherwise fall back to AS baseUrl, then to issuer (back-compat) - resourceServerUrl: options.resourceServerUrl ?? options.baseUrl ?? new URL(oauthMetadata.issuer), - serviceDocumentationUrl: options.serviceDocumentationUrl, - scopesSupported: options.scopesSupported, - resourceName: options.resourceName - }) - ); - - if (oauthMetadata.registration_endpoint) { - router.use( - new URL(oauthMetadata.registration_endpoint).pathname, - clientRegistrationHandler({ - clientsStore: options.provider.clientsStore, - ...options.clientRegistrationOptions - }) - ); - } - - if (oauthMetadata.revocation_endpoint) { - router.use( - new URL(oauthMetadata.revocation_endpoint).pathname, - revocationHandler({ provider: options.provider, ...options.revocationOptions }) - ); - } - - return router; -} - -export type AuthMetadataOptions = { - /** - * OAuth Metadata as would be returned from the authorization server - * this MCP server relies on - */ - oauthMetadata: OAuthMetadata; - - /** - * The url of the MCP server, for use in protected resource metadata - */ - resourceServerUrl: URL; - - /** - * The url for documentation for the MCP server - */ - serviceDocumentationUrl?: URL; - - /** - * An optional list of scopes supported by this MCP server - */ - scopesSupported?: string[]; - - /** - * An optional resource name to display in resource metadata - */ - resourceName?: string; -}; - -export function mcpAuthMetadataRouter(options: AuthMetadataOptions): express.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-specific URL per RFC 9728 - const rsPath = new URL(options.resourceServerUrl.href).pathname; - router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata)); - - // Always add this for OAuth Authorization Server metadata per RFC 8414 - router.use('/.well-known/oauth-authorization-server', metadataHandler(options.oauthMetadata)); - - return router; -} - -/** - * Helper function to construct the OAuth 2.0 Protected Resource Metadata URL - * from a given server URL. This replaces the path with the standard metadata endpoint. - * - * @param serverUrl - The base URL of the protected resource server - * @returns The URL for the OAuth protected resource metadata endpoint - * - * @example - * getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp')) - * // Returns: '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/src/server/auth/types.ts b/src/server/auth/types.ts deleted file mode 100644 index a38a7e750..000000000 --- a/src/server/auth/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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; -} diff --git a/src/server/completable.ts b/src/server/completable.ts deleted file mode 100644 index be067ac55..000000000 --- a/src/server/completable.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AnySchema, SchemaInput } from './zod-compat.js'; - -export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); - -export type CompleteCallback = ( - value: SchemaInput, - context?: { - arguments?: Record; - } -) => SchemaInput[] | Promise[]>; - -export type CompletableMeta = { - complete: CompleteCallback; -}; - -export type CompletableSchema = T & { - [COMPLETABLE_SYMBOL]: CompletableMeta; -}; - -/** - * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. - * Works with both Zod v3 and v4 schemas. - */ -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; -} - -/** - * Unwraps a completable schema to get the underlying schema. - * For backward compatibility with code that called `.unwrap()`. - */ -export function unwrapCompletable(schema: CompletableSchema): T { - return schema; -} - -// Legacy exports for backward compatibility -// These types are deprecated but kept for existing code -export enum McpZodTypeKind { - Completable = 'McpCompletable' -} - -export interface CompletableDef { - type: T; - complete: CompleteCallback; - typeName: McpZodTypeKind.Completable; -} diff --git a/src/server/express.ts b/src/server/express.ts deleted file mode 100644 index a542acd7a..000000000 --- a/src/server/express.ts +++ /dev/null @@ -1,74 +0,0 @@ -import express, { 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[]; -} - -/** - * 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 - * ```typescript - * // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection - * const app = createMcpExpressApp(); - * - * // Custom host - DNS rebinding protection only applied for localhost hosts - * const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection - * const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled - * - * // Custom allowed hosts for non-localhost binding - * 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 } = options; - - const app = express(); - app.use(express.json()); - - // 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/src/server/index.ts b/src/server/index.ts deleted file mode 100644 index 531a559dd..000000000 --- a/src/server/index.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; -import { - type ClientCapabilities, - type CreateMessageRequest, - type CreateMessageResult, - CreateMessageResultSchema, - type CreateMessageResultWithTools, - CreateMessageResultWithToolsSchema, - type CreateMessageRequestParamsBase, - type CreateMessageRequestParamsWithTools, - type ElicitRequestFormParams, - type ElicitRequestURLParams, - type ElicitResult, - ElicitResultSchema, - EmptyResultSchema, - ErrorCode, - type Implementation, - InitializedNotificationSchema, - type InitializeRequest, - InitializeRequestSchema, - type InitializeResult, - LATEST_PROTOCOL_VERSION, - type ListRootsRequest, - ListRootsResultSchema, - type LoggingLevel, - LoggingLevelSchema, - type LoggingMessageNotification, - McpError, - type ResourceUpdatedNotification, - type ServerCapabilities, - type ServerNotification, - type ServerRequest, - type ServerResult, - SetLevelRequestSchema, - SUPPORTED_PROTOCOL_VERSIONS, - type ToolResultContent, - type ToolUseContent, - CallToolRequestSchema, - CallToolResultSchema, - CreateTaskResultSchema, - type Request, - type Notification, - type Result -} from '../types.js'; -import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; -import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; -import { - AnyObjectSchema, - getObjectShape, - isZ4Schema, - safeParse, - SchemaOutput, - type ZodV3Internal, - type ZodV4Internal -} from './zod-compat.js'; -import { RequestHandlerExtra } from '../shared/protocol.js'; -import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../experimental/tasks/helpers.js'; - -export type ServerOptions = ProtocolOptions & { - /** - * Capabilities to advertise as being supported by this server. - */ - capabilities?: ServerCapabilities; - - /** - * 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 AjvJsonSchemaValidator - * - * @example - * ```typescript - * // ajv (default) - * const server = new Server( - * { name: 'my-server', version: '1.0.0' }, - * { - * capabilities: {} - * jsonSchemaValidator: new AjvJsonSchemaValidator() - * } - * ); - * - * // @cfworker/json-schema - * const server = new Server( - * { name: 'my-server', version: '1.0.0' }, - * { - * capabilities: {}, - * jsonSchemaValidator: new CfWorkerJsonSchemaValidator() - * } - * ); - * ``` - */ - 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. - * - * To use with custom types, extend the base Request/Notification/Result types and pass them as type parameters: - * - * ```typescript - * // Custom schemas - * const CustomRequestSchema = RequestSchema.extend({...}) - * const CustomNotificationSchema = NotificationSchema.extend({...}) - * const CustomResultSchema = ResultSchema.extend({...}) - * - * // Type aliases - * type CustomRequest = z.infer - * type CustomNotification = z.infer - * type CustomResult = z.infer - * - * // Create typed server - * const server = new Server({ - * name: "CustomServer", - * version: "1.0.0" - * }) - * ``` - * @deprecated Use `McpServer` instead for the high-level API. Only use `Server` for advanced use cases. - */ -export class Server< - RequestT extends Request = Request, - NotificationT extends Notification = Notification, - ResultT extends Result = Result -> 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 `initialized` notification). - */ - oninitialized?: () => void; - - /** - * Initializes this server with the given name and version information. - */ - constructor( - private _serverInfo: Implementation, - options?: ServerOptions - ) { - super(options); - this._capabilities = options?.capabilities ?? {}; - this._instructions = options?.instructions; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); - - this.setRequestHandler(InitializeRequestSchema, request => this._oninitialize(request)); - this.setNotificationHandler(InitializedNotificationSchema, () => this.oninitialized?.()); - - if (this._capabilities.logging) { - this.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { - const transportSessionId: string | undefined = - extra.sessionId || (extra.requestInfo?.headers['mcp-session-id'] as string) || undefined; - const { level } = request.params; - const parseResult = LoggingLevelSchema.safeParse(level); - if (parseResult.success) { - this._loggingLevels.set(transportSessionId, parseResult.data); - } - return {}; - }); - } - } - - /** - * 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 Error('Cannot register capabilities after connecting to transport'); - } - this._capabilities = mergeCapabilities(this._capabilities, capabilities); - } - - /** - * Override request handler registration to enforce server-side validation for tools/call. - */ - public override setRequestHandler( - requestSchema: T, - handler: ( - request: SchemaOutput, - extra: RequestHandlerExtra - ) => ServerResult | ResultT | Promise - ): void { - const shape = getObjectShape(requestSchema); - const methodSchema = shape?.method; - if (!methodSchema) { - throw new Error('Schema is missing a method literal'); - } - - // Extract literal value using type-safe property access - let methodValue: unknown; - if (isZ4Schema(methodSchema)) { - const v4Schema = methodSchema as unknown as ZodV4Internal; - const v4Def = v4Schema._zod?.def; - methodValue = v4Def?.value ?? v4Schema.value; - } else { - const v3Schema = methodSchema as unknown as ZodV3Internal; - const legacyDef = v3Schema._def; - methodValue = legacyDef?.value ?? v3Schema.value; - } - - if (typeof methodValue !== 'string') { - throw new Error('Schema method literal must be a string'); - } - const method = methodValue; - - if (method === 'tools/call') { - const wrappedHandler = async ( - request: SchemaOutput, - extra: RequestHandlerExtra - ): Promise => { - const validatedRequest = safeParse(CallToolRequestSchema, request); - if (!validatedRequest.success) { - const errorMessage = - validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call request: ${errorMessage}`); - } - - const { params } = validatedRequest.data; - - const result = await Promise.resolve(handler(request, extra)); - - // When task creation is requested, validate and return CreateTaskResult - if (params.task) { - const taskValidationResult = safeParse(CreateTaskResultSchema, result); - if (!taskValidationResult.success) { - const errorMessage = - taskValidationResult.error instanceof Error - ? taskValidationResult.error.message - : String(taskValidationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`); - } - return taskValidationResult.data; - } - - // For non-task requests, validate against CallToolResultSchema - const validationResult = safeParse(CallToolResultSchema, result); - if (!validationResult.success) { - const errorMessage = - validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error); - throw new McpError(ErrorCode.InvalidParams, `Invalid tools/call result: ${errorMessage}`); - } - - return validationResult.data; - }; - - // Install the wrapped handler - return super.setRequestHandler(requestSchema, wrappedHandler as unknown as typeof handler); - } - - // Other handlers use default behavior - return super.setRequestHandler(requestSchema, handler); - } - - protected assertCapabilityForMethod(method: RequestT['method']): void { - switch (method as ServerRequest['method']) { - case 'sampling/createMessage': - if (!this._clientCapabilities?.sampling) { - throw new Error(`Client does not support sampling (required for ${method})`); - } - break; - - case 'elicitation/create': - if (!this._clientCapabilities?.elicitation) { - throw new Error(`Client does not support elicitation (required for ${method})`); - } - break; - - case 'roots/list': - if (!this._clientCapabilities?.roots) { - throw new Error(`Client does not support listing roots (required for ${method})`); - } - break; - - case 'ping': - // No specific capability required for ping - break; - } - } - - protected assertNotificationCapability(method: (ServerNotification | NotificationT)['method']): void { - switch (method as ServerNotification['method']) { - case 'notifications/message': - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'notifications/resources/updated': - case 'notifications/resources/list_changed': - if (!this._capabilities.resources) { - throw new Error(`Server does not support notifying about resources (required for ${method})`); - } - break; - - case 'notifications/tools/list_changed': - if (!this._capabilities.tools) { - throw new Error(`Server does not support notifying of tool list changes (required for ${method})`); - } - break; - - case 'notifications/prompts/list_changed': - if (!this._capabilities.prompts) { - throw new Error(`Server does not support notifying of prompt list changes (required for ${method})`); - } - break; - - case 'notifications/elicitation/complete': - if (!this._clientCapabilities?.elicitation?.url) { - throw new Error(`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 { - // Task handlers are registered in Protocol constructor before _capabilities is initialized - // Skip capability check for task methods during initialization - if (!this._capabilities) { - return; - } - - switch (method) { - case 'completion/complete': - if (!this._capabilities.completions) { - throw new Error(`Server does not support completions (required for ${method})`); - } - break; - - case 'logging/setLevel': - if (!this._capabilities.logging) { - throw new Error(`Server does not support logging (required for ${method})`); - } - break; - - case 'prompts/get': - case 'prompts/list': - if (!this._capabilities.prompts) { - throw new Error(`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 Error(`Server does not support resources (required for ${method})`); - } - break; - - case 'tools/call': - case 'tools/list': - if (!this._capabilities.tools) { - throw new Error(`Server does not support tools (required for ${method})`); - } - break; - - case 'tasks/get': - case 'tasks/list': - case 'tasks/result': - case 'tasks/cancel': - if (!this._capabilities.tasks) { - throw new Error(`Server does not support tasks capability (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 { - // Task handlers are registered in Protocol constructor before _capabilities is initialized - // Skip capability check for task methods during initialization - if (!this._capabilities) { - return; - } - - 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 = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion) ? requestedVersion : LATEST_PROTOCOL_VERSION; - - 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; - } - - private getCapabilities(): ServerCapabilities { - return this._capabilities; - } - - async ping() { - return this.request({ 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) { - if (!this._clientCapabilities?.sampling?.tools) { - throw new Error('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[params.messages.length - 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[params.messages.length - 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 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 Error('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.request({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); - } - return this.request({ 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 Error('Client does not support url elicitation.'); - } - - const urlParams = params as ElicitRequestURLParams; - return this.request({ method: 'elicitation/create', params: urlParams }, ElicitResultSchema, options); - } - case 'form': { - if (!this._clientCapabilities?.elicitation?.form) { - throw new Error('Client does not support form elicitation.'); - } - - const formParams: ElicitRequestFormParams = - params.mode === 'form' ? (params as ElicitRequestFormParams) : { ...(params as ElicitRequestFormParams), mode: 'form' }; - - const result = await this.request({ 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 McpError( - ErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` - ); - } - } catch (error) { - if (error instanceof McpError) { - throw error; - } - throw new McpError( - ErrorCode.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 Error('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.request({ 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 LoggingMessageNotification - * @param params - * @param sessionId optional for stateless and backward compatibility - */ - async sendLoggingMessage(params: LoggingMessageNotification['params'], sessionId?: string) { - if (this._capabilities.logging) { - if (!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/src/server/mcp.ts b/src/server/mcp.ts deleted file mode 100644 index 7e61b4364..000000000 --- a/src/server/mcp.ts +++ /dev/null @@ -1,1542 +0,0 @@ -import { Server, ServerOptions } from './index.js'; -import { - AnySchema, - AnyObjectSchema, - ZodRawShapeCompat, - SchemaOutput, - ShapeOutput, - normalizeObjectSchema, - safeParseAsync, - getObjectShape, - objectFromShape, - getParseErrorMessage, - getSchemaDescription, - isSchemaOptional, - getLiteralValue -} from './zod-compat.js'; -import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; -import { - Implementation, - Tool, - ListToolsResult, - CallToolResult, - McpError, - ErrorCode, - CompleteResult, - PromptReference, - ResourceTemplateReference, - BaseMetadata, - Resource, - ListResourcesResult, - ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema, - ListToolsRequestSchema, - CallToolRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema, - CompleteRequestSchema, - ListPromptsResult, - Prompt, - PromptArgument, - GetPromptResult, - ReadResourceResult, - ServerRequest, - ServerNotification, - ToolAnnotations, - LoggingMessageNotification, - CreateTaskResult, - Result, - CompleteRequestPrompt, - CompleteRequestResourceTemplate, - assertCompleteRequestPrompt, - assertCompleteRequestResourceTemplate, - CallToolRequest, - ToolExecution -} from '../types.js'; -import { isCompletable, getCompleter } from './completable.js'; -import { UriTemplate, Variables } from '../shared/uriTemplate.js'; -import { RequestHandlerExtra } from '../shared/protocol.js'; -import { Transport } from '../shared/transport.js'; - -import { validateAndWarnToolName } from '../shared/toolNameValidation.js'; -import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js'; -import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; -import { ZodOptional } from 'zod'; - -/** - * 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 - * Server instance available via the `server` property. - */ -export class McpServer { - /** - * The underlying 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 Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. - */ - 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(getMethodValue(ListToolsRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(CallToolRequestSchema)); - - this.server.registerCapabilities({ - tools: { - listChanged: true - } - }); - - this.server.setRequestHandler( - ListToolsRequestSchema, - (): 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: (() => { - const obj = normalizeObjectSchema(tool.inputSchema); - return obj - ? (toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: 'input' - }) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA; - })(), - annotations: tool.annotations, - execution: tool.execution, - _meta: tool._meta - }; - - if (tool.outputSchema) { - const obj = normalizeObjectSchema(tool.outputSchema); - if (obj) { - toolDefinition.outputSchema = toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: 'output' - }) as Tool['outputSchema']; - } - } - - return toolDefinition; - }) - }) - ); - - this.server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { - try { - const tool = this._registeredTools[request.params.name]; - if (!tool) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); - } - if (!tool.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} disabled`); - } - - 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 McpError( - ErrorCode.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 McpError( - ErrorCode.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, extra); - } - - // Normal execution path - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const result = await this.executeToolHandler(tool, args, extra); - - // 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 McpError) { - if (error.code === ErrorCode.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< - Tool extends RegisteredTool, - Args extends Tool['inputSchema'] extends infer InputSchema - ? InputSchema extends AnySchema - ? SchemaOutput - : undefined - : undefined - >(tool: Tool, args: Args, toolName: string): Promise { - if (!tool.inputSchema) { - return undefined as Args; - } - - // Try to normalize to object schema first (for raw shapes and object schemas) - // If that fails, use the schema directly (for union/intersection/etc) - const inputObj = normalizeObjectSchema(tool.inputSchema); - const schemaToParse = inputObj ?? (tool.inputSchema as AnySchema); - const parseResult = await safeParseAsync(schemaToParse, args); - if (!parseResult.success) { - const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; - const errorMessage = getParseErrorMessage(error); - throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`); - } - - 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 McpError( - ErrorCode.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 outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema; - const parseResult = await safeParseAsync(outputObj, result.structuredContent); - if (!parseResult.success) { - const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; - const errorMessage = getParseErrorMessage(error); - throw new McpError( - ErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}` - ); - } - } - - /** - * Executes a tool handler (either regular or task-based). - */ - private async executeToolHandler( - tool: RegisteredTool, - args: unknown, - extra: RequestHandlerExtra - ): Promise { - const handler = tool.handler as AnyToolHandler; - const isTaskHandler = 'createTask' in handler; - - if (isTaskHandler) { - if (!extra.taskStore) { - throw new Error('No task store provided.'); - } - const taskExtra = { ...extra, taskStore: extra.taskStore }; - - if (tool.inputSchema) { - const typedHandler = handler as ToolTaskHandler; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await Promise.resolve(typedHandler.createTask(args as any, taskExtra)); - } else { - const typedHandler = handler as ToolTaskHandler; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await Promise.resolve((typedHandler.createTask as any)(taskExtra)); - } - } - - if (tool.inputSchema) { - const typedHandler = handler as ToolCallback; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await Promise.resolve(typedHandler(args as any, extra)); - } else { - const typedHandler = handler as ToolCallback; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await Promise.resolve((typedHandler as any)(extra)); - } - } - - /** - * Handles automatic task polling for tools with taskSupport 'optional'. - */ - private async handleAutomaticTaskPolling( - tool: RegisteredTool, - request: RequestT, - extra: RequestHandlerExtra - ): Promise { - if (!extra.taskStore) { - throw new Error('No task store provided for task-capable tool.'); - } - - // Validate input and create task - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); - const handler = tool.handler as ToolTaskHandler; - const taskExtra = { ...extra, taskStore: extra.taskStore }; - - const createTaskResult: CreateTaskResult = args // undefined only if tool.inputSchema is undefined - ? await Promise.resolve((handler as ToolTaskHandler).createTask(args, taskExtra)) - : // eslint-disable-next-line @typescript-eslint/no-explicit-any - await Promise.resolve(((handler as ToolTaskHandler).createTask as any)(taskExtra)); - - // 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 extra.taskStore.getTask(taskId); - if (!updatedTask) { - throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`); - } - task = updatedTask; - } - - // Return the final result - return (await extra.taskStore.getTaskResult(taskId)) as CallToolResult; - } - - private _completionHandlerInitialized = false; - - private setCompletionRequestHandler() { - if (this._completionHandlerInitialized) { - return; - } - - this.server.assertCanSetRequestHandler(getMethodValue(CompleteRequestSchema)); - - this.server.registerCapabilities({ - completions: {} - }); - - this.server.setRequestHandler(CompleteRequestSchema, 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 McpError(ErrorCode.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 McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} not found`); - } - - if (!prompt.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${ref.name} disabled`); - } - - if (!prompt.argsSchema) { - return EMPTY_COMPLETION_RESULT; - } - - const promptShape = getObjectShape(prompt.argsSchema); - const field = 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 McpError(ErrorCode.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(getMethodValue(ListResourcesRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(ListResourceTemplatesRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(ReadResourceRequestSchema)); - - this.server.registerCapabilities({ - resources: { - listChanged: true - } - }); - - this.server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { - 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(extra); - 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(ListResourceTemplatesRequestSchema, async () => { - const resourceTemplates = Object.entries(this._registeredResourceTemplates).map(([name, template]) => ({ - name, - uriTemplate: template.resourceTemplate.uriTemplate.toString(), - ...template.metadata - })); - - return { resourceTemplates }; - }); - - this.server.setRequestHandler(ReadResourceRequestSchema, async (request, extra) => { - 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 McpError(ErrorCode.InvalidParams, `Resource ${uri} disabled`); - } - return resource.readCallback(uri, extra); - } - - // 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, extra); - } - } - - throw new McpError(ErrorCode.InvalidParams, `Resource ${uri} not found`); - }); - - this._resourceHandlersInitialized = true; - } - - private _promptHandlersInitialized = false; - - private setPromptRequestHandlers() { - if (this._promptHandlersInitialized) { - return; - } - - this.server.assertCanSetRequestHandler(getMethodValue(ListPromptsRequestSchema)); - this.server.assertCanSetRequestHandler(getMethodValue(GetPromptRequestSchema)); - - this.server.registerCapabilities({ - prompts: { - listChanged: true - } - }); - - this.server.setRequestHandler( - ListPromptsRequestSchema, - (): 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 ? promptArgumentsFromSchema(prompt.argsSchema) : undefined - }; - }) - }) - ); - - this.server.setRequestHandler(GetPromptRequestSchema, async (request, extra): Promise => { - const prompt = this._registeredPrompts[request.params.name]; - if (!prompt) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} not found`); - } - - if (!prompt.enabled) { - throw new McpError(ErrorCode.InvalidParams, `Prompt ${request.params.name} disabled`); - } - - if (prompt.argsSchema) { - const argsObj = normalizeObjectSchema(prompt.argsSchema) as AnyObjectSchema; - const parseResult = await safeParseAsync(argsObj, request.params.arguments); - if (!parseResult.success) { - const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; - const errorMessage = getParseErrorMessage(error); - throw new McpError(ErrorCode.InvalidParams, `Invalid arguments for prompt ${request.params.name}: ${errorMessage}`); - } - - const args = parseResult.data; - const cb = prompt.callback as PromptCallback; - return await Promise.resolve(cb(args, extra)); - } else { - const cb = prompt.callback as PromptCallback; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await Promise.resolve((cb as any)(extra)); - } - }); - - this._promptHandlersInitialized = true; - } - - /** - * Registers a resource `name` at a fixed URI, which will use the given callback to respond to read requests. - * @deprecated Use `registerResource` instead. - */ - resource(name: string, uri: string, readCallback: ReadResourceCallback): RegisteredResource; - - /** - * Registers a resource `name` at a fixed URI with metadata, which will use the given callback to respond to read requests. - * @deprecated Use `registerResource` instead. - */ - resource(name: string, uri: string, metadata: ResourceMetadata, readCallback: ReadResourceCallback): RegisteredResource; - - /** - * Registers a resource `name` with a template pattern, which will use the given callback to respond to read requests. - * @deprecated Use `registerResource` instead. - */ - resource(name: string, template: ResourceTemplate, readCallback: ReadResourceTemplateCallback): RegisteredResourceTemplate; - - /** - * Registers a resource `name` with a template pattern and metadata, which will use the given callback to respond to read requests. - * @deprecated Use `registerResource` instead. - */ - resource( - name: string, - template: ResourceTemplate, - metadata: ResourceMetadata, - readCallback: ReadResourceTemplateCallback - ): RegisteredResourceTemplate; - - resource(name: string, uriOrTemplate: string | ResourceTemplate, ...rest: unknown[]): RegisteredResource | RegisteredResourceTemplate { - let metadata: ResourceMetadata | undefined; - if (typeof rest[0] === 'object') { - metadata = rest.shift() as ResourceMetadata; - } - - const readCallback = rest[0] as ReadResourceCallback | ReadResourceTemplateCallback; - - if (typeof uriOrTemplate === 'string') { - if (this._registeredResources[uriOrTemplate]) { - throw new Error(`Resource ${uriOrTemplate} is already registered`); - } - - const registeredResource = this._createRegisteredResource( - name, - undefined, - uriOrTemplate, - metadata, - 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, - undefined, - uriOrTemplate, - metadata, - readCallback as ReadResourceTemplateCallback - ); - - this.setResourceRequestHandlers(); - this.sendResourceListChanged(); - return registeredResourceTemplate; - } - } - - /** - * Registers a resource with a config object and callback. - * For static resources, use a URI string. For dynamic resources, use a ResourceTemplate. - */ - 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 (typeof updates.uri !== 'undefined' && updates.uri !== uri) { - delete this._registeredResources[uri]; - if (updates.uri) this._registeredResources[updates.uri] = registeredResource; - } - if (typeof updates.name !== 'undefined') registeredResource.name = updates.name; - if (typeof updates.title !== 'undefined') registeredResource.title = updates.title; - if (typeof updates.metadata !== 'undefined') registeredResource.metadata = updates.metadata; - if (typeof updates.callback !== 'undefined') registeredResource.readCallback = updates.callback; - if (typeof 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 (typeof updates.name !== 'undefined' && updates.name !== name) { - delete this._registeredResourceTemplates[name]; - if (updates.name) this._registeredResourceTemplates[updates.name] = registeredResourceTemplate; - } - if (typeof updates.title !== 'undefined') registeredResourceTemplate.title = updates.title; - if (typeof updates.template !== 'undefined') registeredResourceTemplate.resourceTemplate = updates.template; - if (typeof updates.metadata !== 'undefined') registeredResourceTemplate.metadata = updates.metadata; - if (typeof updates.callback !== 'undefined') registeredResourceTemplate.readCallback = updates.callback; - if (typeof 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: PromptArgsRawShape | undefined, - callback: PromptCallback - ): RegisteredPrompt { - const registeredPrompt: RegisteredPrompt = { - title, - description, - argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), - callback, - enabled: true, - disable: () => registeredPrompt.update({ enabled: false }), - enable: () => registeredPrompt.update({ enabled: true }), - remove: () => registeredPrompt.update({ name: null }), - update: updates => { - if (typeof updates.name !== 'undefined' && updates.name !== name) { - delete this._registeredPrompts[name]; - if (updates.name) this._registeredPrompts[updates.name] = registeredPrompt; - } - if (typeof updates.title !== 'undefined') registeredPrompt.title = updates.title; - if (typeof updates.description !== 'undefined') registeredPrompt.description = updates.description; - if (typeof updates.argsSchema !== 'undefined') registeredPrompt.argsSchema = objectFromShape(updates.argsSchema); - if (typeof updates.callback !== 'undefined') registeredPrompt.callback = updates.callback; - if (typeof 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 hasCompletable = Object.values(argsSchema).some(field => { - const inner: unknown = field instanceof ZodOptional ? field._def?.innerType : field; - return isCompletable(inner); - }); - if (hasCompletable) { - this.setCompletionRequestHandler(); - } - } - - return registeredPrompt; - } - - private _createRegisteredTool( - name: string, - title: string | undefined, - description: string | undefined, - inputSchema: ZodRawShapeCompat | AnySchema | undefined, - outputSchema: ZodRawShapeCompat | AnySchema | undefined, - annotations: ToolAnnotations | undefined, - execution: ToolExecution | undefined, - _meta: Record | undefined, - handler: AnyToolHandler - ): RegisteredTool { - // Validate tool name according to SEP specification - validateAndWarnToolName(name); - - const registeredTool: RegisteredTool = { - title, - description, - inputSchema: getZodSchemaObject(inputSchema), - outputSchema: getZodSchemaObject(outputSchema), - annotations, - execution, - _meta, - handler: handler, - enabled: true, - disable: () => registeredTool.update({ enabled: false }), - enable: () => registeredTool.update({ enabled: true }), - remove: () => registeredTool.update({ name: null }), - update: updates => { - if (typeof 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 (typeof updates.title !== 'undefined') registeredTool.title = updates.title; - if (typeof updates.description !== 'undefined') registeredTool.description = updates.description; - if (typeof updates.paramsSchema !== 'undefined') registeredTool.inputSchema = objectFromShape(updates.paramsSchema); - if (typeof updates.outputSchema !== 'undefined') registeredTool.outputSchema = objectFromShape(updates.outputSchema); - if (typeof updates.callback !== 'undefined') registeredTool.handler = updates.callback; - if (typeof updates.annotations !== 'undefined') registeredTool.annotations = updates.annotations; - if (typeof updates._meta !== 'undefined') registeredTool._meta = updates._meta; - if (typeof updates.enabled !== 'undefined') registeredTool.enabled = updates.enabled; - this.sendToolListChanged(); - } - }; - this._registeredTools[name] = registeredTool; - - this.setToolRequestHandlers(); - this.sendToolListChanged(); - - return registeredTool; - } - - /** - * Registers a zero-argument tool `name`, which will run the given function when the client calls it. - * @deprecated Use `registerTool` instead. - */ - tool(name: string, cb: ToolCallback): RegisteredTool; - - /** - * Registers a zero-argument tool `name` (with a description) which will run the given function when the client calls it. - * @deprecated Use `registerTool` instead. - */ - tool(name: string, description: string, cb: ToolCallback): RegisteredTool; - - /** - * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. - * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. - * - * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate - * between ToolAnnotations and ZodRawShapeCompat during overload resolution, as both are plain object types. - * @deprecated Use `registerTool` instead. - */ - tool( - name: string, - paramsSchemaOrAnnotations: Args | ToolAnnotations, - cb: ToolCallback - ): RegisteredTool; - - /** - * Registers a tool `name` (with a description) taking either parameter schema or annotations. - * This unified overload handles both `tool(name, description, paramsSchema, cb)` and - * `tool(name, description, annotations, cb)` cases. - * - * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate - * between ToolAnnotations and ZodRawShapeCompat during overload resolution, as both are plain object types. - * @deprecated Use `registerTool` instead. - */ - tool( - name: string, - description: string, - paramsSchemaOrAnnotations: Args | ToolAnnotations, - cb: ToolCallback - ): RegisteredTool; - - /** - * Registers a tool with both parameter schema and annotations. - * @deprecated Use `registerTool` instead. - */ - tool( - name: string, - paramsSchema: Args, - annotations: ToolAnnotations, - cb: ToolCallback - ): RegisteredTool; - - /** - * Registers a tool with description, parameter schema, and annotations. - * @deprecated Use `registerTool` instead. - */ - tool( - name: string, - description: string, - paramsSchema: Args, - annotations: ToolAnnotations, - cb: ToolCallback - ): RegisteredTool; - - /** - * tool() implementation. Parses arguments passed to overrides defined above. - */ - tool(name: string, ...rest: unknown[]): RegisteredTool { - if (this._registeredTools[name]) { - throw new Error(`Tool ${name} is already registered`); - } - - let description: string | undefined; - let inputSchema: ZodRawShapeCompat | undefined; - let outputSchema: ZodRawShapeCompat | undefined; - let annotations: ToolAnnotations | undefined; - - // Tool properties are passed as separate arguments, with omissions allowed. - // Support for this style is frozen as of protocol version 2025-03-26. Future additions - // to tool definition should *NOT* be added. - - if (typeof rest[0] === 'string') { - description = rest.shift() as string; - } - - // Handle the different overload combinations - if (rest.length > 1) { - // We have at least one more arg before the callback - const firstArg = rest[0]; - - if (isZodRawShapeCompat(firstArg)) { - // We have a params schema as the first arg - inputSchema = rest.shift() as ZodRawShapeCompat; - - // Check if the next arg is potentially annotations - if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShapeCompat(rest[0])) { - // Case: tool(name, paramsSchema, annotations, cb) - // Or: tool(name, description, paramsSchema, annotations, cb) - annotations = rest.shift() as ToolAnnotations; - } - } else if (typeof firstArg === 'object' && firstArg !== null) { - // Not a ZodRawShapeCompat, so must be annotations in this position - // Case: tool(name, annotations, cb) - // Or: tool(name, description, annotations, cb) - annotations = rest.shift() as ToolAnnotations; - } - } - const callback = rest[0] as ToolCallback; - - return this._createRegisteredTool( - name, - undefined, - description, - inputSchema, - outputSchema, - annotations, - { taskSupport: 'forbidden' }, - undefined, - callback - ); - } - - /** - * Registers a tool with a config object and callback. - */ - registerTool( - name: string, - config: { - title?: string; - description?: string; - inputSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record; - }, - cb: ToolCallback - ): 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, - inputSchema, - outputSchema, - annotations, - { taskSupport: 'forbidden' }, - _meta, - cb as ToolCallback - ); - } - - /** - * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a zero-argument prompt `name` (with a description) which will run the given function when the client calls it. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, description: string, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a prompt `name` accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. - * @deprecated Use `registerPrompt` instead. - */ - prompt(name: string, argsSchema: Args, cb: PromptCallback): RegisteredPrompt; - - /** - * Registers a prompt `name` (with a description) accepting the given arguments, which must be an object containing named properties associated with Zod schemas. When the client calls it, the function will be run with the parsed and validated arguments. - * @deprecated Use `registerPrompt` instead. - */ - prompt( - name: string, - description: string, - argsSchema: Args, - cb: PromptCallback - ): RegisteredPrompt; - - prompt(name: string, ...rest: unknown[]): RegisteredPrompt { - if (this._registeredPrompts[name]) { - throw new Error(`Prompt ${name} is already registered`); - } - - let description: string | undefined; - if (typeof rest[0] === 'string') { - description = rest.shift() as string; - } - - let argsSchema: PromptArgsRawShape | undefined; - if (rest.length > 1) { - argsSchema = rest.shift() as PromptArgsRawShape; - } - - const cb = rest[0] as PromptCallback; - const registeredPrompt = this._createRegisteredPrompt(name, undefined, description, argsSchema, cb); - - this.setPromptRequestHandlers(); - this.sendPromptListChanged(); - - return registeredPrompt; - } - - /** - * Registers a prompt with a config object and callback. - */ - registerPrompt( - name: string, - config: { - title?: string; - description?: string; - argsSchema?: Args; - }, - cb: PromptCallback - ): RegisteredPrompt { - if (this._registeredPrompts[name]) { - throw new Error(`Prompt ${name} is already registered`); - } - - const { title, description, argsSchema } = config; - - const registeredPrompt = this._createRegisteredPrompt( - name, - title, - description, - argsSchema, - cb as PromptCallback - ); - - 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 LoggingMessageNotification - * @param params - * @param sessionId optional for stateless and backward compatibility - */ - 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 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]; - } -} - -export type BaseToolCallback< - SendResultT extends Result, - Extra extends RequestHandlerExtra, - Args extends undefined | ZodRawShapeCompat | AnySchema -> = Args extends ZodRawShapeCompat - ? (args: ShapeOutput, extra: Extra) => SendResultT | Promise - : Args extends AnySchema - ? (args: SchemaOutput, extra: Extra) => SendResultT | Promise - : (extra: Extra) => SendResultT | Promise; - -/** - * Callback for a tool handler registered with Server.tool(). - * - * Parameters will include tool arguments, if applicable, as well as other request handler context. - * - * The callback should return: - * - `structuredContent` if the tool has an outputSchema defined - * - `content` if the tool does not have an outputSchema - * - Both fields are optional but typically one should be provided - */ -export type ToolCallback = BaseToolCallback< - CallToolResult, - RequestHandlerExtra, - Args ->; - -/** - * Supertype that can handle both regular tools (simple callback) and task-based tools (task handler object). - */ -export type AnyToolHandler = ToolCallback | ToolTaskHandler; - -export type RegisteredTool = { - title?: string; - description?: string; - inputSchema?: AnySchema; - outputSchema?: AnySchema; - annotations?: ToolAnnotations; - execution?: ToolExecution; - _meta?: Record; - handler: AnyToolHandler; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - paramsSchema?: InputArgs; - outputSchema?: OutputArgs; - annotations?: ToolAnnotations; - _meta?: Record; - callback?: ToolCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -const EMPTY_OBJECT_JSON_SCHEMA = { - type: 'object' as const, - properties: {} -}; - -/** - * Checks if a value looks like a Zod schema by checking for parse/safeParse methods. - */ -function isZodTypeLike(value: unknown): value is AnySchema { - return ( - value !== null && - typeof value === 'object' && - 'parse' in value && - typeof value.parse === 'function' && - 'safeParse' in value && - typeof value.safeParse === 'function' - ); -} - -/** - * Checks if an object is a Zod schema instance (v3 or v4). - * - * Zod schemas have internal markers: - * - v3: `_def` property - * - v4: `_zod` property - * - * This includes transformed schemas like z.preprocess(), z.transform(), z.pipe(). - */ -function isZodSchemaInstance(obj: object): boolean { - return '_def' in obj || '_zod' in obj || isZodTypeLike(obj); -} - -/** - * Checks if an object is a "raw shape" - a plain object where values are Zod schemas. - * - * Raw shapes are used as shorthand: `{ name: z.string() }` instead of `z.object({ name: z.string() })`. - * - * IMPORTANT: This must NOT match actual Zod schema instances (like z.preprocess, z.pipe), - * which have internal properties that could be mistaken for schema values. - */ -function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - // If it's already a Zod schema instance, it's NOT a raw shape - if (isZodSchemaInstance(obj)) { - return false; - } - - // Empty objects are valid raw shapes (tools with no parameters) - if (Object.keys(obj).length === 0) { - return true; - } - - // A raw shape has at least one property that is a Zod schema - return Object.values(obj).some(isZodTypeLike); -} - -/** - * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, - * otherwise returns the schema as is. - */ -function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): AnySchema | undefined { - if (!schema) { - return undefined; - } - - if (isZodRawShapeCompat(schema)) { - return objectFromShape(schema); - } - - return schema; -} - -/** - * Additional, optional information for annotating a resource. - */ -export type ResourceMetadata = Omit; - -/** - * Callback to list all resources matching a given template. - */ -export type ListResourcesCallback = ( - extra: RequestHandlerExtra -) => ListResourcesResult | Promise; - -/** - * Callback to read a resource at a given URI. - */ -export type ReadResourceCallback = ( - uri: URL, - extra: RequestHandlerExtra -) => 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, - extra: RequestHandlerExtra -) => 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; -}; - -type PromptArgsRawShape = ZodRawShapeCompat; - -export type PromptCallback = Args extends PromptArgsRawShape - ? (args: ShapeOutput, extra: RequestHandlerExtra) => GetPromptResult | Promise - : (extra: RequestHandlerExtra) => GetPromptResult | Promise; - -export type RegisteredPrompt = { - title?: string; - description?: string; - argsSchema?: AnyObjectSchema; - callback: PromptCallback; - enabled: boolean; - enable(): void; - disable(): void; - update(updates: { - name?: string | null; - title?: string; - description?: string; - argsSchema?: Args; - callback?: PromptCallback; - enabled?: boolean; - }): void; - remove(): void; -}; - -function promptArgumentsFromSchema(schema: AnyObjectSchema): PromptArgument[] { - const shape = getObjectShape(schema); - if (!shape) return []; - return Object.entries(shape).map(([name, field]): PromptArgument => { - // Get description - works for both v3 and v4 - const description = getSchemaDescription(field); - // Check if optional - works for both v3 and v4 - const isOptional = isSchemaOptional(field); - return { - name, - description, - required: !isOptional - }; - }); -} - -function getMethodValue(schema: AnyObjectSchema): string { - const shape = getObjectShape(schema); - const methodSchema = shape?.method as AnySchema | undefined; - if (!methodSchema) { - throw new Error('Schema is missing a method literal'); - } - - // Extract literal value - works for both v3 and v4 - const value = getLiteralValue(methodSchema); - if (typeof value === 'string') { - return value; - } - - throw new Error('Schema method literal must be a string'); -} - -function createCompletionResult(suggestions: string[]): CompleteResult { - return { - completion: { - values: suggestions.slice(0, 100), - total: suggestions.length, - hasMore: suggestions.length > 100 - } - }; -} - -const EMPTY_COMPLETION_RESULT: CompleteResult = { - completion: { - values: [], - hasMore: false - } -}; diff --git a/src/server/middleware/hostHeaderValidation.ts b/src/server/middleware/hostHeaderValidation.ts deleted file mode 100644 index 165003635..000000000 --- a/src/server/middleware/hostHeaderValidation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Request, Response, NextFunction, RequestHandler } 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 - * ```typescript - * 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 hostHeader = req.headers.host; - if (!hostHeader) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Missing Host header' - }, - id: null - }); - return; - } - - // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) - let hostname: string; - try { - hostname = new URL(`http://${hostHeader}`).hostname; - } catch { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host header: ${hostHeader}` - }, - id: null - }); - return; - } - - if (!allowedHostnames.includes(hostname)) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host: ${hostname}` - }, - id: null - }); - return; - } - next(); - }; -} - -/** - * Convenience middleware for localhost DNS rebinding protection. - * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. - * - * @example - * ```typescript - * app.use(localhostHostValidation()); - * ``` - */ -export function localhostHostValidation(): RequestHandler { - return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); -} diff --git a/src/server/sse.ts b/src/server/sse.ts deleted file mode 100644 index b7450a09e..000000000 --- a/src/server/sse.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../types.js'; -import getRawBody from 'raw-body'; -import contentType from 'content-type'; -import { AuthInfo } from './auth/types.js'; -import { URL } from 'node:url'; - -const MAXIMUM_MESSAGE_SIZE = '4mb'; - -/** - * Configuration options for SSEServerTransport. - */ -export interface SSEServerTransportOptions { - /** - * List of allowed host header values for DNS rebinding protection. - * If not specified, host validation is disabled. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedHosts?: string[]; - - /** - * List of allowed origin header values for DNS rebinding protection. - * If not specified, origin validation is disabled. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedOrigins?: string[]; - - /** - * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). - * Default is false for backwards compatibility. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - enableDnsRebindingProtection?: boolean; -} - -/** - * Server transport for SSE: this will send messages over an SSE connection and receive messages from HTTP POST requests. - * - * This transport is only available in Node.js environments. - * @deprecated SSEServerTransport is deprecated. Use StreamableHTTPServerTransport instead. - */ -export class SSEServerTransport implements Transport { - private _sseResponse?: ServerResponse; - private _sessionId: string; - private _options: SSEServerTransportOptions; - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - - /** - * Creates a new SSE server transport, which will direct the client to POST messages to the relative or absolute URL identified by `_endpoint`. - */ - constructor( - private _endpoint: string, - private res: ServerResponse, - options?: SSEServerTransportOptions - ) { - this._sessionId = randomUUID(); - this._options = options || { enableDnsRebindingProtection: false }; - } - - /** - * Validates request headers for DNS rebinding protection. - * @returns Error message if validation fails, undefined if validation passes. - */ - private validateRequestHeaders(req: IncomingMessage): string | undefined { - // Skip validation if protection is not enabled - if (!this._options.enableDnsRebindingProtection) { - return undefined; - } - - // Validate Host header if allowedHosts is configured - if (this._options.allowedHosts && this._options.allowedHosts.length > 0) { - const hostHeader = req.headers.host; - if (!hostHeader || !this._options.allowedHosts.includes(hostHeader)) { - return `Invalid Host header: ${hostHeader}`; - } - } - - // Validate Origin header if allowedOrigins is configured - if (this._options.allowedOrigins && this._options.allowedOrigins.length > 0) { - const originHeader = req.headers.origin; - if (originHeader && !this._options.allowedOrigins.includes(originHeader)) { - return `Invalid Origin header: ${originHeader}`; - } - } - - return undefined; - } - - /** - * Handles the initial SSE connection request. - * - * This should be called when a GET request is made to establish the SSE stream. - */ - async start(): Promise { - if (this._sseResponse) { - throw new Error('SSEServerTransport already started! If using Server class, note that connect() calls start() automatically.'); - } - - this.res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive' - }); - - // Send the endpoint event - // Use a dummy base URL because this._endpoint is relative. - // This allows using URL/URLSearchParams for robust parameter handling. - const dummyBase = 'http://localhost'; // Any valid base works - const endpointUrl = new URL(this._endpoint, dummyBase); - endpointUrl.searchParams.set('sessionId', this._sessionId); - - // Reconstruct the relative URL string (pathname + search + hash) - const relativeUrlWithSession = endpointUrl.pathname + endpointUrl.search + endpointUrl.hash; - - this.res.write(`event: endpoint\ndata: ${relativeUrlWithSession}\n\n`); - - this._sseResponse = this.res; - this.res.on('close', () => { - this._sseResponse = undefined; - this.onclose?.(); - }); - } - - /** - * Handles incoming POST messages. - * - * This should be called when a POST request is made to send a message to the server. - */ - async handlePostMessage(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { - if (!this._sseResponse) { - const message = 'SSE connection not established'; - res.writeHead(500).end(message); - throw new Error(message); - } - - // Validate request headers for DNS rebinding protection - const validationError = this.validateRequestHeaders(req); - if (validationError) { - res.writeHead(403).end(validationError); - this.onerror?.(new Error(validationError)); - return; - } - - const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; - - let body: string | unknown; - try { - const ct = contentType.parse(req.headers['content-type'] ?? ''); - if (ct.type !== 'application/json') { - throw new Error(`Unsupported content-type: ${ct.type}`); - } - - body = - parsedBody ?? - (await getRawBody(req, { - limit: MAXIMUM_MESSAGE_SIZE, - encoding: ct.parameters.charset ?? 'utf-8' - })); - } catch (error) { - res.writeHead(400).end(String(error)); - this.onerror?.(error as Error); - return; - } - - try { - await this.handleMessage(typeof body === 'string' ? JSON.parse(body) : body, { requestInfo, authInfo }); - } catch { - res.writeHead(400).end(`Invalid message: ${body}`); - return; - } - - res.writeHead(202).end('Accepted'); - } - - /** - * Handle a client message, regardless of how it arrived. This can be used to inform the server of messages that arrive via a means different than HTTP POST. - */ - async handleMessage(message: unknown, extra?: MessageExtraInfo): Promise { - let parsedMessage: JSONRPCMessage; - try { - parsedMessage = JSONRPCMessageSchema.parse(message); - } catch (error) { - this.onerror?.(error as Error); - throw error; - } - - this.onmessage?.(parsedMessage, extra); - } - - async close(): Promise { - this._sseResponse?.end(); - this._sseResponse = undefined; - this.onclose?.(); - } - - async send(message: JSONRPCMessage): Promise { - if (!this._sseResponse) { - throw new Error('Not connected'); - } - - this._sseResponse.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`); - } - - /** - * Returns the session ID for this transport. - * - * This can be used to route incoming POST requests. - */ - get sessionId(): string { - return this._sessionId; - } -} diff --git a/src/server/stdio.ts b/src/server/stdio.ts deleted file mode 100644 index e552af0fa..000000000 --- a/src/server/stdio.ts +++ /dev/null @@ -1,92 +0,0 @@ -import process from 'node:process'; -import { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../shared/stdio.js'; -import { JSONRPCMessage } from '../types.js'; -import { Transport } from '../shared/transport.js'; - -/** - * 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. - */ -export class StdioServerTransport implements Transport { - private _readBuffer: ReadBuffer = new ReadBuffer(); - private _started = 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); - }; - - /** - * 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); - } - - 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 { - // Remove our event listeners first - this._stdin.off('data', this._ondata); - this._stdin.off('error', this._onerror); - - // 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 { - return new Promise(resolve => { - const json = serializeMessage(message); - if (this._stdout.write(json)) { - resolve(); - } else { - this._stdout.once('drain', resolve); - } - }); - } -} diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts deleted file mode 100644 index ab1131f63..000000000 --- a/src/server/streamableHttp.ts +++ /dev/null @@ -1,969 +0,0 @@ -import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../shared/transport.js'; -import { - MessageExtraInfo, - RequestInfo, - isInitializeRequest, - isJSONRPCRequest, - isJSONRPCResultResponse, - JSONRPCMessage, - JSONRPCMessageSchema, - RequestId, - SUPPORTED_PROTOCOL_VERSIONS, - DEFAULT_NEGOTIATED_PROTOCOL_VERSION, - isJSONRPCErrorResponse -} from '../types.js'; -import getRawBody from 'raw-body'; -import contentType from 'content-type'; -import { randomUUID } from 'node:crypto'; -import { AuthInfo } from './auth/types.js'; - -const MAXIMUM_MESSAGE_SIZE = '4mb'; - -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 - * replayEventsAfter for stream mapping. - */ - getStreamIdForEventId?(eventId: EventId): Promise; - - replayEventsAfter( - lastEventId: EventId, - { - send - }: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - } - ): Promise; -} - -/** - * Configuration options for StreamableHTTPServerTransport - */ -export interface StreamableHTTPServerTransportOptions { - /** - * 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) - * - * Return undefined to disable session management. - */ - 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; - - /** - * 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 - * StreamableHTTPServerTransport 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; - - /** - * 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 the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedHosts?: string[]; - - /** - * List of allowed origin header values for DNS rebinding protection. - * If not specified, origin validation is disabled. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - allowedOrigins?: string[]; - - /** - * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). - * Default is false for backwards compatibility. - * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, - * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/express.js` which includes localhost protection by default. - */ - 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; -} - -/** - * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. - * It supports both SSE streaming and direct HTTP responses. - * - * Usage example: - * - * ```typescript - * // Stateful mode - server sets the session ID - * const statefulTransport = new StreamableHTTPServerTransport({ - * sessionIdGenerator: () => randomUUID(), - * }); - * - * // Stateless mode - explicitly set session ID to undefined - * const statelessTransport = new StreamableHTTPServerTransport({ - * sessionIdGenerator: undefined, - * }); - * - * // Using with pre-parsed request body - * app.post('/mcp', (req, res) => { - * transport.handleRequest(req, res, req.body); - * }); - * ``` - * - * 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 - */ -export class StreamableHTTPServerTransport 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 _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; - private _onsessionclosed?: (sessionId: string) => void | Promise; - private _allowedHosts?: string[]; - private _allowedOrigins?: string[]; - private _enableDnsRebindingProtection: boolean; - private _retryInterval?: number; - - sessionId?: string; - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - - constructor(options: StreamableHTTPServerTransportOptions) { - 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; - } - - /** - * Starts the transport. This is required by the 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; - } - - /** - * Validates request headers for DNS rebinding protection. - * @returns Error message if validation fails, undefined if validation passes. - */ - private validateRequestHeaders(req: IncomingMessage): string | 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.host; - if (!hostHeader || !this._allowedHosts.includes(hostHeader)) { - return `Invalid Host header: ${hostHeader}`; - } - } - - // Validate Origin header if allowedOrigins is configured - if (this._allowedOrigins && this._allowedOrigins.length > 0) { - const originHeader = req.headers.origin; - if (originHeader && !this._allowedOrigins.includes(originHeader)) { - return `Invalid Origin header: ${originHeader}`; - } - } - - return undefined; - } - - /** - * Handles an incoming HTTP request, whether GET or POST - */ - async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { - // Validate request headers for DNS rebinding protection - const validationError = this.validateRequestHeaders(req); - if (validationError) { - res.writeHead(403).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: validationError - }, - id: null - }) - ); - this.onerror?.(new Error(validationError)); - return; - } - - if (req.method === 'POST') { - await this.handlePostRequest(req, res, parsedBody); - } else if (req.method === 'GET') { - await this.handleGetRequest(req, res); - } else if (req.method === 'DELETE') { - await this.handleDeleteRequest(req, res); - } else { - await this.handleUnsupportedRequest(res); - } - } - - /** - * 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 _maybeWritePrimingEvent(res: ServerResponse, 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`; - } - res.write(primingEvent); - } - - /** - * Handles GET requests for SSE stream - */ - private async handleGetRequest(req: IncomingMessage, res: ServerResponse): Promise { - // The client MUST include an Accept header, listing text/event-stream as a supported content type. - const acceptHeader = req.headers.accept; - if (!acceptHeader?.includes('text/event-stream')) { - res.writeHead(406).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Not Acceptable: Client must accept text/event-stream' - }, - id: null - }) - ); - return; - } - - // 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. - if (!this.validateSession(req, res)) { - return; - } - if (!this.validateProtocolVersion(req, res)) { - return; - } - // Handle resumability: check for Last-Event-ID header - if (this._eventStore) { - const lastEventId = req.headers['last-event-id'] as string | undefined; - if (lastEventId) { - await this.replayEvents(lastEventId, res); - return; - } - } - - // The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, - // or else return HTTP 405 Method Not Allowed - 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; - } - - // 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 - res.writeHead(409).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Conflict: Only one SSE stream is allowed per session' - }, - id: null - }) - ); - return; - } - - // We need to send headers immediately as messages will arrive much later, - // otherwise the client will just wait for the first message - res.writeHead(200, headers).flushHeaders(); - - // Assign the response to the standalone SSE stream - this._streamMapping.set(this._standaloneSseStreamId, res); - // Set up close handler for client disconnects - res.on('close', () => { - this._streamMapping.delete(this._standaloneSseStreamId); - }); - - // Add error handler for standalone SSE stream - res.on('error', error => { - this.onerror?.(error as Error); - }); - } - - /** - * Replays events that would have been sent after the specified event ID - * Only used when resumability is enabled - */ - private async replayEvents(lastEventId: string, res: ServerResponse): Promise { - if (!this._eventStore) { - return; - } - 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) { - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Invalid event ID format' - }, - id: null - }) - ); - return; - } - - // Check conflict with the SAME streamId we'll use for mapping - if (this._streamMapping.get(streamId) !== undefined) { - res.writeHead(409).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Conflict: Stream already has an active connection' - }, - id: null - }) - ); - return; - } - } - - 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; - } - res.writeHead(200, headers).flushHeaders(); - - // Replay events - returns the streamId for backwards compatibility - const replayedStreamId = await this._eventStore.replayEventsAfter(lastEventId, { - send: async (eventId: string, message: JSONRPCMessage) => { - if (!this.writeSSEEvent(res, message, eventId)) { - this.onerror?.(new Error('Failed replay events')); - res.end(); - } - } - }); - - this._streamMapping.set(replayedStreamId, res); - - // Set up close handler for client disconnects - res.on('close', () => { - this._streamMapping.delete(replayedStreamId); - }); - - // Add error handler for replay stream - res.on('error', error => { - this.onerror?.(error as Error); - }); - } catch (error) { - this.onerror?.(error as Error); - } - } - - /** - * Writes an event to the SSE stream with proper formatting - */ - private writeSSEEvent(res: ServerResponse, message: JSONRPCMessage, eventId?: string): boolean { - 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`; - - return res.write(eventData); - } - - /** - * Handles unsupported requests (PUT, PATCH, etc.) - */ - private async handleUnsupportedRequest(res: ServerResponse): Promise { - res.writeHead(405, { - Allow: 'GET, POST, DELETE' - }).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed.' - }, - id: null - }) - ); - } - - /** - * Handles POST requests containing JSON-RPC messages - */ - private async handlePostRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { - try { - // Validate the Accept header - const acceptHeader = req.headers.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')) { - res.writeHead(406).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Not Acceptable: Client must accept both application/json and text/event-stream' - }, - id: null - }) - ); - return; - } - - const ct = req.headers['content-type']; - if (!ct || !ct.includes('application/json')) { - res.writeHead(415).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Unsupported Media Type: Content-Type must be application/json' - }, - id: null - }) - ); - return; - } - - const authInfo: AuthInfo | undefined = req.auth; - const requestInfo: RequestInfo = { headers: req.headers }; - - let rawMessage; - if (parsedBody !== undefined) { - rawMessage = parsedBody; - } else { - const parsedCt = contentType.parse(ct); - const body = await getRawBody(req, { - limit: MAXIMUM_MESSAGE_SIZE, - encoding: parsedCt.parameters.charset ?? 'utf-8' - }); - rawMessage = JSON.parse(body.toString()); - } - - let messages: JSONRPCMessage[]; - - // handle batch and single messages - if (Array.isArray(rawMessage)) { - messages = rawMessage.map(msg => JSONRPCMessageSchema.parse(msg)); - } else { - messages = [JSONRPCMessageSchema.parse(rawMessage)]; - } - - // Check if this is an initialization request - // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ - const isInitializationRequest = messages.some(isInitializeRequest); - 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) { - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Invalid Request: Server already initialized' - }, - id: null - }) - ); - return; - } - if (messages.length > 1) { - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32600, - message: 'Invalid Request: Only one initialization request is allowed' - }, - id: null - }) - ); - return; - } - 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. - if (!this.validateSession(req, res)) { - return; - } - // Mcp-Protocol-Version header is required for all requests after initialization. - if (!this.validateProtocolVersion(req, res)) { - return; - } - } - - // check if it contains requests - const hasRequests = messages.some(isJSONRPCRequest); - - if (!hasRequests) { - // if it only contains notifications or responses, return 202 - res.writeHead(202).end(); - - // handle each message - for (const message of messages) { - this.onmessage?.(message, { authInfo, requestInfo }); - } - } else if (hasRequests) { - // The default behavior is to use SSE streaming - // but in some cases server will return JSON responses - const streamId = 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['mcp-protocol-version'] as string) ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - - if (!this._enableJsonResponse) { - 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; - } - - res.writeHead(200, headers); - - await this._maybeWritePrimingEvent(res, streamId, clientProtocolVersion); - } - // 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, res); - this._requestToStreamMapping.set(message.id, streamId); - } - } - // Set up close handler for client disconnects - res.on('close', () => { - this._streamMapping.delete(streamId); - }); - - // Add error handler for stream write errors - res.on('error', error => { - this.onerror?.(error as Error); - }); - - // 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, requestInfo, 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 - } - } catch (error) { - // return JSON-RPC formatted error - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32700, - message: 'Parse error', - data: String(error) - }, - id: null - }) - ); - this.onerror?.(error as Error); - } - } - - /** - * Handles DELETE requests to terminate sessions - */ - private async handleDeleteRequest(req: IncomingMessage, res: ServerResponse): Promise { - if (!this.validateSession(req, res)) { - return; - } - if (!this.validateProtocolVersion(req, res)) { - return; - } - await Promise.resolve(this._onsessionclosed?.(this.sessionId!)); - await this.close(); - res.writeHead(200).end(); - } - - /** - * Validates session ID for non-initialization requests - * Returns true if the session is valid, false otherwise - */ - private validateSession(req: IncomingMessage, res: ServerResponse): boolean { - 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 true; - } - if (!this._initialized) { - // If the server has not been initialized yet, reject all requests - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Server not initialized' - }, - id: null - }) - ); - return false; - } - - const sessionId = req.headers['mcp-session-id']; - - if (!sessionId) { - // Non-initialization requests without a session ID should return 400 Bad Request - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Mcp-Session-Id header is required' - }, - id: null - }) - ); - return false; - } else if (Array.isArray(sessionId)) { - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Bad Request: Mcp-Session-Id header must be a single value' - }, - id: null - }) - ); - return false; - } else if (sessionId !== this.sessionId) { - // Reject requests with invalid session ID with 404 Not Found - res.writeHead(404).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32001, - message: 'Session not found' - }, - id: null - }) - ); - return false; - } - - return true; - } - - /** - * 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: IncomingMessage, res: ServerResponse): boolean { - let protocolVersion = req.headers['mcp-protocol-version']; - if (Array.isArray(protocolVersion)) { - protocolVersion = protocolVersion[protocolVersion.length - 1]; - } - - if (protocolVersion !== undefined && !SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { - res.writeHead(400).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Bad Request: Unsupported protocol version: ${protocolVersion} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` - }, - id: null - }) - ); - return false; - } - return true; - } - - async close(): Promise { - // Close all SSE connections - this._streamMapping.forEach(response => { - response.end(); - }); - 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.end(); - this._streamMapping.delete(streamId); - } - } - - /** - * 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.end(); - this._streamMapping.delete(this._standaloneSseStreamId); - } - } - - 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 - this.writeSSEEvent(standaloneSse, message, eventId); - return; - } - - // Get the response for this request - const streamId = this._requestToStreamMapping.get(requestId); - const response = this._streamMapping.get(streamId!); - if (!streamId) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - - if (!this._enableJsonResponse) { - // 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); - } - if (response) { - // Write the event to the response stream - this.writeSSEEvent(response, message, eventId); - } - } - - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - this._requestResponseMap.set(requestId, message); - const relatedIds = Array.from(this._requestToStreamMapping.entries()) - .filter(([_, streamId]) => this._streamMapping.get(streamId) === response) - .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 (!response) { - throw new Error(`No connection established for request ID: ${String(requestId)}`); - } - if (this._enableJsonResponse) { - // 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)!); - - response.writeHead(200, headers); - if (responses.length === 1) { - response.end(JSON.stringify(responses[0])); - } else { - response.end(JSON.stringify(responses)); - } - } else { - // End the SSE stream - response.end(); - } - // Clean up - for (const id of relatedIds) { - this._requestResponseMap.delete(id); - this._requestToStreamMapping.delete(id); - } - } - } - } -} diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts deleted file mode 100644 index 04ee5361f..000000000 --- a/src/server/zod-compat.ts +++ /dev/null @@ -1,280 +0,0 @@ -// zod-compat.ts -// ---------------------------------------------------- -// Unified types + helpers to accept Zod v3 and v4 (Mini) -// ---------------------------------------------------- - -import type * as z3 from 'zod/v3'; -import type * as z4 from 'zod/v4/core'; - -import * as z3rt from 'zod/v3'; -import * as z4mini from 'zod/v4-mini'; - -// --- Unified schema types --- -export type AnySchema = z3.ZodTypeAny | z4.$ZodType; -export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject | AnySchema; -export type ZodRawShapeCompat = Record; - -// --- Internal property access helpers --- -// These types help us safely access internal properties that differ between v3 and v4 -export interface ZodV3Internal { - _def?: { - typeName?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - shape?: Record | (() => Record); - value?: unknown; -} - -export interface ZodV4Internal { - _zod?: { - def?: { - type?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - }; - value?: unknown; -} - -// --- Type inference helpers --- -export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends z4.$ZodType ? z4.output : never; - -export type SchemaInput = S extends z3.ZodTypeAny ? z3.input : S extends z4.$ZodType ? z4.input : never; - -/** - * Infers the output type from a ZodRawShapeCompat (raw shape object). - * Maps over each key in the shape and infers the output type from each schema. - */ -export type ShapeOutput = { - [K in keyof Shape]: SchemaOutput; -}; - -// --- Runtime detection --- -export function isZ4Schema(s: AnySchema): s is z4.$ZodType { - // Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3 - const schema = s as unknown as ZodV4Internal; - return !!schema._zod; -} - -// --- Schema construction --- -export function objectFromShape(shape: ZodRawShapeCompat): AnyObjectSchema { - const values = Object.values(shape); - if (values.length === 0) return z4mini.object({}); // default to v4 Mini - - const allV4 = values.every(isZ4Schema); - const allV3 = values.every(s => !isZ4Schema(s)); - - if (allV4) return z4mini.object(shape as Record); - if (allV3) return z3rt.object(shape as Record); - - throw new Error('Mixed Zod versions detected in object shape.'); -} - -// --- Unified parsing --- -export function safeParse( - schema: S, - data: unknown -): { success: true; data: SchemaOutput } | { success: false; error: unknown } { - if (isZ4Schema(schema)) { - // Mini exposes top-level safeParse - const result = z4mini.safeParse(schema, data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; - } - const v3Schema = schema as z3.ZodTypeAny; - const result = v3Schema.safeParse(data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; -} - -export async function safeParseAsync( - schema: S, - data: unknown -): Promise<{ success: true; data: SchemaOutput } | { success: false; error: unknown }> { - if (isZ4Schema(schema)) { - // Mini exposes top-level safeParseAsync - const result = await z4mini.safeParseAsync(schema, data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; - } - const v3Schema = schema as z3.ZodTypeAny; - const result = await v3Schema.safeParseAsync(data); - return result as { success: true; data: SchemaOutput } | { success: false; error: unknown }; -} - -// --- Shape extraction --- -export function getObjectShape(schema: AnyObjectSchema | undefined): Record | undefined { - if (!schema) return undefined; - - // Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape` - let rawShape: Record | (() => Record) | undefined; - - if (isZ4Schema(schema)) { - const v4Schema = schema as unknown as ZodV4Internal; - rawShape = v4Schema._zod?.def?.shape; - } else { - const v3Schema = schema as unknown as ZodV3Internal; - rawShape = v3Schema.shape; - } - - if (!rawShape) return undefined; - - if (typeof rawShape === 'function') { - try { - return rawShape(); - } catch { - return undefined; - } - } - - return rawShape; -} - -// --- Schema normalization --- -/** - * Normalizes a schema to an object schema. Handles both: - * - Already-constructed object schemas (v3 or v4) - * - Raw shapes that need to be wrapped into object schemas - */ -export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | undefined): AnyObjectSchema | undefined { - if (!schema) return undefined; - - // First check if it's a raw shape (Record) - // Raw shapes don't have _def or _zod properties and aren't schemas themselves - if (typeof schema === 'object') { - // Check if it's actually a ZodRawShapeCompat (not a schema instance) - // by checking if it lacks schema-like internal properties - const asV3 = schema as unknown as ZodV3Internal; - const asV4 = schema as unknown as ZodV4Internal; - - // If it's not a schema instance (no _def or _zod), it might be a raw shape - if (!asV3._def && !asV4._zod) { - // Check if all values are schemas (heuristic to confirm it's a raw shape) - const values = Object.values(schema); - if ( - values.length > 0 && - values.every( - v => - typeof v === 'object' && - v !== null && - ((v as unknown as ZodV3Internal)._def !== undefined || - (v as unknown as ZodV4Internal)._zod !== undefined || - typeof (v as { parse?: unknown }).parse === 'function') - ) - ) { - return objectFromShape(schema as ZodRawShapeCompat); - } - } - } - - // If we get here, it should be an AnySchema (not a raw shape) - // Check if it's already an object schema - if (isZ4Schema(schema as AnySchema)) { - // Check if it's a v4 object - const v4Schema = schema as unknown as ZodV4Internal; - const def = v4Schema._zod?.def; - if (def && (def.type === 'object' || def.shape !== undefined)) { - return schema as AnyObjectSchema; - } - } else { - // Check if it's a v3 object - const v3Schema = schema as unknown as ZodV3Internal; - if (v3Schema.shape !== undefined) { - return schema as AnyObjectSchema; - } - } - - return undefined; -} - -// --- Error message extraction --- -/** - * Safely extracts an error message from a parse result error. - * Zod errors can have different structures, so we handle various cases. - */ -export function getParseErrorMessage(error: unknown): string { - if (error && typeof error === 'object') { - // Try common error structures - if ('message' in error && typeof error.message === 'string') { - return error.message; - } - if ('issues' in error && Array.isArray(error.issues) && error.issues.length > 0) { - const firstIssue = error.issues[0]; - if (firstIssue && typeof firstIssue === 'object' && 'message' in firstIssue) { - return String(firstIssue.message); - } - } - // Fallback: try to stringify the error - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - return String(error); -} - -// --- Schema metadata access --- -/** - * Gets the description from a schema, if available. - * Works with both Zod v3 and v4. - */ -export function getSchemaDescription(schema: AnySchema): string | undefined { - if (isZ4Schema(schema)) { - const v4Schema = schema as unknown as ZodV4Internal; - return v4Schema._zod?.def?.description; - } - const v3Schema = schema as unknown as ZodV3Internal; - // v3 may have description on the schema itself or in _def - return (schema as { description?: string }).description ?? v3Schema._def?.description; -} - -/** - * Checks if a schema is optional. - * Works with both Zod v3 and v4. - */ -export function isSchemaOptional(schema: AnySchema): boolean { - if (isZ4Schema(schema)) { - const v4Schema = schema as unknown as ZodV4Internal; - return v4Schema._zod?.def?.type === 'optional'; - } - const v3Schema = schema as unknown as ZodV3Internal; - // v3 has isOptional() method - if (typeof (schema as { isOptional?: () => boolean }).isOptional === 'function') { - return (schema as { isOptional: () => boolean }).isOptional(); - } - return v3Schema._def?.typeName === 'ZodOptional'; -} - -/** - * Gets the literal value from a schema, if it's a literal schema. - * Works with both Zod v3 and v4. - * Returns undefined if the schema is not a literal or the value cannot be determined. - */ -export function getLiteralValue(schema: AnySchema): unknown { - if (isZ4Schema(schema)) { - const v4Schema = schema as unknown as ZodV4Internal; - const def = v4Schema._zod?.def; - if (def) { - // Try various ways to get the literal value - if (def.value !== undefined) return def.value; - if (Array.isArray(def.values) && def.values.length > 0) { - return def.values[0]; - } - } - } - const v3Schema = schema as unknown as ZodV3Internal; - const def = v3Schema._def; - if (def) { - if (def.value !== undefined) return def.value; - if (Array.isArray(def.values) && def.values.length > 0) { - return def.values[0]; - } - } - // Fallback: check for direct value property (some Zod versions) - const directValue = (schema as { value?: unknown }).value; - if (directValue !== undefined) return directValue; - return undefined; -} diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts deleted file mode 100644 index cde66b177..000000000 --- a/src/server/zod-json-schema-compat.ts +++ /dev/null @@ -1,68 +0,0 @@ -// zod-json-schema-compat.ts -// ---------------------------------------------------- -// JSON Schema conversion for both Zod v3 and Zod v4 (Mini) -// v3 uses your vendored converter; v4 uses Mini's toJSONSchema -// ---------------------------------------------------- - -import type * as z3 from 'zod/v3'; -import type * as z4c from 'zod/v4/core'; - -import * as z4mini from 'zod/v4-mini'; - -import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -type JsonSchema = Record; - -// Options accepted by call sites; we map them appropriately -type CommonOpts = { - strictUnions?: boolean; - pipeStrategy?: 'input' | 'output'; - target?: 'jsonSchema7' | 'draft-7' | 'jsonSchema2019-09' | 'draft-2020-12'; -}; - -function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft-2020-12' { - if (!t) return 'draft-7'; - if (t === 'jsonSchema7' || t === 'draft-7') return 'draft-7'; - if (t === 'jsonSchema2019-09' || t === 'draft-2020-12') return 'draft-2020-12'; - return 'draft-7'; // fallback -} - -export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { - if (isZ4Schema(schema)) { - // v4 branch — use Mini's built-in toJSONSchema - return z4mini.toJSONSchema(schema as z4c.$ZodType, { - target: mapMiniTarget(opts?.target), - io: opts?.pipeStrategy ?? 'input' - }) as JsonSchema; - } - - // v3 branch — use vendored converter - return zodToJsonSchema(schema as z3.ZodTypeAny, { - strictUnions: opts?.strictUnions ?? true, - pipeStrategy: opts?.pipeStrategy ?? 'input' - }) as JsonSchema; -} - -export function getMethodLiteral(schema: AnyObjectSchema): string { - const shape = getObjectShape(schema); - const methodSchema = shape?.method as AnySchema | undefined; - if (!methodSchema) { - throw new Error('Schema is missing a method literal'); - } - - const value = getLiteralValue(methodSchema); - if (typeof value !== 'string') { - throw new Error('Schema method literal must be a string'); - } - - return value; -} - -export function parseWithCompat(schema: AnySchema, data: unknown): unknown { - const result = safeParse(schema, data); - if (!result.success) { - throw result.error; - } - return result.data; -} diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts deleted file mode 100644 index c9863da43..000000000 --- a/src/shared/auth-utils.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Utilities for handling OAuth resource URIs. - */ - -/** - * Converts a server URL to a resource URL by removing the fragment. - * 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 requestedResource The resource URL being requested - * @param 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/src/shared/auth.ts b/src/shared/auth.ts deleted file mode 100644 index c546c8608..000000000 --- a/src/shared/auth.ts +++ /dev/null @@ -1,231 +0,0 @@ -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(); - -/** - * 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 SafeUrlSchema that allows empty string for retrocompatibility on tos_uri and logo_uri - */ -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/src/shared/protocol.ts b/src/shared/protocol.ts deleted file mode 100644 index aa242a647..000000000 --- a/src/shared/protocol.ts +++ /dev/null @@ -1,1657 +0,0 @@ -import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js'; -import { - CancelledNotificationSchema, - ClientCapabilities, - CreateTaskResultSchema, - ErrorCode, - GetTaskRequest, - GetTaskRequestSchema, - GetTaskResultSchema, - GetTaskPayloadRequest, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - ListTasksResultSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, - isJSONRPCErrorResponse, - isJSONRPCRequest, - isJSONRPCResultResponse, - isJSONRPCNotification, - JSONRPCErrorResponse, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - McpError, - PingRequestSchema, - Progress, - ProgressNotification, - ProgressNotificationSchema, - RELATED_TASK_META_KEY, - RequestId, - Result, - ServerCapabilities, - RequestMeta, - MessageExtraInfo, - RequestInfo, - GetTaskResult, - TaskCreationParams, - RelatedTaskMetadata, - CancelledNotification, - Task, - TaskStatusNotification, - TaskStatusNotificationSchema, - Request, - Notification, - JSONRPCResultResponse, - isTaskAugmentedRequestParams -} from '../types.js'; -import { Transport, TransportSendOptions } from './transport.js'; -import { AuthInfo } from '../server/auth/types.js'; -import { isTerminal, TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; -import { getMethodLiteral, parseWithCompat } from '../server/zod-json-schema-compat.js'; -import { ResponseMessage } from './responseMessage.js'; - -/** - * Callback for progress notifications. - */ -export type ProgressCallback = (progress: Progress) => void; - -/** - * Additional initialization options. - */ -export type ProtocolOptions = { - /** - * 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[]; - /** - * Optional task storage implementation. If provided, enables task-related request handlers - * and provides task storage capabilities to request handlers. - */ - 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 5000ms 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. - * When the limit is exceeded, the TaskMessageQueue implementation's enqueue() method - * will throw an error. It's the implementation's responsibility to handle overflow - * appropriately (e.g., by failing the task, dropping messages, etc.). - */ - maxTaskQueueSize?: number; -}; - -/** - * The default request timeout, in miliseconds. - */ -export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60000; - -/** - * 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 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 request(). - */ - signal?: AbortSignal; - - /** - * A timeout (in milliseconds) for this request. If exceeded, an McpError with code `RequestTimeout` will be raised from request(). - * - * If not specified, `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 McpError with code `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; -}; - -/** - * 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 }>; -} - -/** - * Extra data given to request handlers. - */ -export type RequestHandlerExtra = { - /** - * An abort signal used to communicate if the request was cancelled from the sender's side. - */ - signal: AbortSignal; - - /** - * Information about a validated access token, provided to request handlers. - */ - authInfo?: AuthInfo; - - /** - * The session ID from the transport, if available. - */ - sessionId?: string; - - /** - * Metadata from the original request. - */ - _meta?: RequestMeta; - - /** - * The JSON-RPC ID of the request being handled. - * This can be useful for tracking or logging purposes. - */ - requestId: RequestId; - - taskId?: string; - - taskStore?: RequestTaskStore; - - taskRequestedTtl?: number | null; - - /** - * The original HTTP request. - */ - requestInfo?: RequestInfo; - - /** - * Sends a notification that relates to the current request being handled. - * - * This is used by certain transports to correctly associate related messages. - */ - sendNotification: (notification: SendNotificationT) => Promise; - - /** - * Sends a request that relates to the current request being handled. - * - * This is used by certain transports to correctly associate related messages. - */ - sendRequest: (request: SendRequestT, resultSchema: U, options?: TaskRequestOptions) => Promise>; - - /** - * Closes the SSE stream for this request, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. - * Use this to implement polling behavior during long-running operations. - */ - closeSSEStream?: () => void; - - /** - * Closes the standalone GET SSE stream, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. - * Use this to implement polling behavior for server-initiated notifications. - */ - closeStandaloneSSEStream?: () => void; -}; - -/** - * 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. - */ -export abstract class Protocol { - private _transport?: Transport; - private _requestMessageId = 0; - private _requestHandlers: Map< - string, - (request: JSONRPCRequest, extra: RequestHandlerExtra) => 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(); - - // Maps task IDs to progress tokens to keep handlers alive after CreateTaskResult - private _taskProgressTokens: Map = new Map(); - - private _taskStore?: TaskStore; - private _taskMessageQueue?: TaskMessageQueue; - - private _requestResolvers: Map void> = new Map(); - - /** - * Callback for when the connection is closed for any reason. - * - * This is invoked when 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, extra: RequestHandlerExtra) => 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.setNotificationHandler(CancelledNotificationSchema, notification => { - this._oncancel(notification); - }); - - this.setNotificationHandler(ProgressNotificationSchema, notification => { - this._onprogress(notification as unknown as ProgressNotification); - }); - - this.setRequestHandler( - PingRequestSchema, - // Automatic pong by default. - _request => ({}) as SendResultT - ); - - // Install task handlers if TaskStore is provided - this._taskStore = _options?.taskStore; - this._taskMessageQueue = _options?.taskMessageQueue; - if (this._taskStore) { - this.setRequestHandler(GetTaskRequestSchema, async (request, extra) => { - const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - } - - // Per spec: tasks/get responses SHALL NOT include related-task metadata - // as the taskId parameter is the source of truth - // @ts-expect-error SendResultT cannot contain GetTaskResult, but we include it in our derived types everywhere else - return { - ...task - } as SendResultT; - }); - - this.setRequestHandler(GetTaskPayloadRequestSchema, async (request, extra) => { - const handleTaskResult = async (): Promise => { - const taskId = request.params.taskId; - - // Deliver queued messages - if (this._taskMessageQueue) { - let queuedMessage: QueuedMessage | undefined; - while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, extra.sessionId))) { - // Handle response and error messages by routing them to the appropriate resolver - if (queuedMessage.type === 'response' || queuedMessage.type === 'error') { - const message = queuedMessage.message; - const requestId = message.id; - - // Lookup resolver in _requestResolvers map - const resolver = this._requestResolvers.get(requestId as RequestId); - - if (resolver) { - // Remove resolver from map after invocation - this._requestResolvers.delete(requestId as RequestId); - - // Invoke resolver with response or error - if (queuedMessage.type === 'response') { - resolver(message as JSONRPCResultResponse); - } else { - // Convert JSONRPCError to McpError - const errorMessage = message as JSONRPCErrorResponse; - const error = new McpError( - errorMessage.error.code, - errorMessage.error.message, - errorMessage.error.data - ); - resolver(error); - } - } else { - // Handle missing resolver gracefully with error logging - const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error'; - this._onerror(new Error(`${messageType} handler missing for request ${requestId}`)); - } - - // Continue to next message - continue; - } - - // 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 this._transport?.send(queuedMessage.message, { relatedRequestId: extra.requestId }); - } - } - - // Now check task status - const task = await this._taskStore!.getTask(taskId, extra.sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task not found: ${taskId}`); - } - - // Block if task is not terminal (we've already delivered all queued messages above) - if (!isTerminal(task.status)) { - // Wait for status change or new messages - await this._waitForTaskUpdate(taskId, extra.signal); - - // After waking up, recursively call to deliver any new messages or result - return await handleTaskResult(); - } - - // If task is terminal, return the result - if (isTerminal(task.status)) { - const result = await this._taskStore!.getTaskResult(taskId, extra.sessionId); - - this._clearTaskQueue(taskId); - - return { - ...result, - _meta: { - ...result._meta, - [RELATED_TASK_META_KEY]: { - taskId: taskId - } - } - } as SendResultT; - } - - return await handleTaskResult(); - }; - - return await handleTaskResult(); - }); - - this.setRequestHandler(ListTasksRequestSchema, async (request, extra) => { - try { - const { tasks, nextCursor } = await this._taskStore!.listTasks(request.params?.cursor, extra.sessionId); - // @ts-expect-error SendResultT cannot contain ListTasksResult, but we include it in our derived types everywhere else - return { - tasks, - nextCursor, - _meta: {} - } as SendResultT; - } catch (error) { - throw new McpError( - ErrorCode.InvalidParams, - `Failed to list tasks: ${error instanceof Error ? error.message : String(error)}` - ); - } - }); - - this.setRequestHandler(CancelTaskRequestSchema, async (request, extra) => { - try { - // Get the current task to check if it's in a terminal state, in case the implementation is not atomic - const task = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); - - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task not found: ${request.params.taskId}`); - } - - // Reject cancellation of terminal tasks - if (isTerminal(task.status)) { - throw new McpError(ErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`); - } - - await this._taskStore!.updateTaskStatus( - request.params.taskId, - 'cancelled', - 'Client cancelled task execution.', - extra.sessionId - ); - - this._clearTaskQueue(request.params.taskId); - - const cancelledTask = await this._taskStore!.getTask(request.params.taskId, extra.sessionId); - if (!cancelledTask) { - // Task was deleted during cancellation (e.g., cleanup happened) - throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`); - } - - return { - _meta: {}, - ...cancelledTask - } as unknown as SendResultT; - } catch (error) { - // Re-throw McpError as-is - if (error instanceof McpError) { - throw error; - } - throw new McpError( - ErrorCode.InvalidRequest, - `Failed to cancel task: ${error instanceof Error ? error.message : String(error)}` - ); - } - }); - } - } - - 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 McpError.fromError(ErrorCode.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 Protocol object assumes ownership of the Transport, replacing any callbacks that have already been set, and expects that it is the only user of the Transport instance going forward. - */ - async connect(transport: Transport): Promise { - this._transport = transport; - const _onclose = this.transport?.onclose; - this._transport.onclose = () => { - _onclose?.(); - 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)}`)); - } - }; - - await this._transport.start(); - } - - private _onclose(): void { - const responseHandlers = this._responseHandlers; - this._responseHandlers = new Map(); - this._progressHandlers.clear(); - this._taskProgressTokens.clear(); - this._pendingDebouncedNotifications.clear(); - - const error = McpError.fromError(ErrorCode.ConnectionClosed, 'Connection closed'); - - this._transport = undefined; - this.onclose?.(); - - for (const handler of responseHandlers.values()) { - handler(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; - - // Extract taskId from request metadata if present (needed early for method not found case) - const relatedTaskId = request.params?._meta?.[RELATED_TASK_META_KEY]?.taskId; - - if (handler === undefined) { - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: ErrorCode.MethodNotFound, - message: 'Method not found' - } - }; - - // Queue or send the error response based on whether this is a task-related request - if (relatedTaskId && this._taskMessageQueue) { - this._enqueueTaskMessage( - relatedTaskId, - { - type: 'error', - message: errorResponse, - timestamp: Date.now() - }, - capturedTransport?.sessionId - ).catch(error => this._onerror(new Error(`Failed to enqueue error response: ${error}`))); - } else { - capturedTransport - ?.send(errorResponse) - .catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); - } - return; - } - - const abortController = new AbortController(); - this._requestHandlerAbortControllers.set(request.id, abortController); - - const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined; - const taskStore = this._taskStore ? this.requestTaskStore(request, capturedTransport?.sessionId) : undefined; - - const fullExtra: RequestHandlerExtra = { - signal: abortController.signal, - sessionId: capturedTransport?.sessionId, - _meta: request.params?._meta, - sendNotification: async notification => { - // Include related-task metadata if this request is part of a task - const notificationOptions: NotificationOptions = { relatedRequestId: request.id }; - if (relatedTaskId) { - notificationOptions.relatedTask = { taskId: relatedTaskId }; - } - await this.notification(notification, notificationOptions); - }, - sendRequest: async (r, resultSchema, options?) => { - // Include related-task metadata if this request is part of a task - const requestOptions: RequestOptions = { ...options, relatedRequestId: request.id }; - if (relatedTaskId && !requestOptions.relatedTask) { - requestOptions.relatedTask = { taskId: relatedTaskId }; - } - - // Set task status to input_required when sending a request within a task context - // Use the taskId from options (explicit) or fall back to relatedTaskId (inherited) - const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId; - if (effectiveTaskId && taskStore) { - await taskStore.updateTaskStatus(effectiveTaskId, 'input_required'); - } - - return await this.request(r, resultSchema, requestOptions); - }, - authInfo: extra?.authInfo, - requestId: request.id, - requestInfo: extra?.requestInfo, - taskId: relatedTaskId, - taskStore: taskStore, - taskRequestedTtl: taskCreationParams?.ttl, - closeSSEStream: extra?.closeSSEStream, - closeStandaloneSSEStream: extra?.closeStandaloneSSEStream - }; - - // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => { - // If this request asked for task creation, check capability first - if (taskCreationParams) { - // Check if the request method supports task creation - this.assertTaskHandlerCapability(request.method); - } - }) - .then(() => handler(request, fullExtra)) - .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 - if (relatedTaskId && this._taskMessageQueue) { - await this._enqueueTaskMessage( - relatedTaskId, - { - type: 'response', - message: response, - timestamp: Date.now() - }, - capturedTransport?.sessionId - ); - } else { - 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'] : ErrorCode.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 - if (relatedTaskId && this._taskMessageQueue) { - await this._enqueueTaskMessage( - relatedTaskId, - { - type: 'error', - message: errorResponse, - timestamp: Date.now() - }, - capturedTransport?.sessionId - ); - } else { - await capturedTransport?.send(errorResponse); - } - } - ) - .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) - .finally(() => { - 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); - - // Check if this is a response to a queued request - const resolver = this._requestResolvers.get(messageId); - if (resolver) { - this._requestResolvers.delete(messageId); - if (isJSONRPCResultResponse(response)) { - resolver(response); - } else { - const error = new McpError(response.error.code, response.error.message, response.error.data); - resolver(error); - } - return; - } - - 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 - let isTaskResponse = false; - 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') { - isTaskResponse = true; - this._taskProgressTokens.set(task.taskId, messageId); - } - } - } - - if (!isTaskResponse) { - this._progressHandlers.delete(messageId); - } - - if (isJSONRPCResultResponse(response)) { - handler(response); - } else { - const error = McpError.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: SendRequestT['method']): 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: SendNotificationT['method']): 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 task creation is supported for the given request method. - * - * This should be implemented by subclasses. - */ - protected abstract assertTaskCapability(method: string): void; - - /** - * A method to check if task handler is supported by the local side, for the given method to be handled. - * - * This should be implemented by subclasses. - */ - protected abstract assertTaskHandlerCapability(method: string): void; - - /** - * Sends a request and returns an AsyncGenerator that yields response messages. - * The generator is guaranteed to end with either a 'result' or 'error' message. - * - * @example - * ```typescript - * const stream = protocol.requestStream(request, resultSchema, 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; - * } - * } - * ``` - * - * @experimental Use `client.experimental.tasks.requestStream()` to access this method. - */ - protected async *requestStream( - request: SendRequestT, - resultSchema: T, - options?: RequestOptions - ): AsyncGenerator>, void, void> { - const { task } = options ?? {}; - - // For non-task requests, just yield the result - if (!task) { - try { - const result = await this.request(request, resultSchema, options); - yield { type: 'result', result }; - } catch (error) { - yield { - type: 'error', - error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error)) - }; - } - return; - } - - // For task-augmented requests, we need to poll for status - // First, make the request to create the task - let taskId: string | undefined; - try { - // Send the request and get the CreateTaskResult - const createResult = await this.request(request, CreateTaskResultSchema, options); - - // Extract taskId from the result - if (createResult.task) { - taskId = createResult.task.taskId; - yield { type: 'taskCreated', task: createResult.task }; - } else { - throw new McpError(ErrorCode.InternalError, 'Task creation did not return a task'); - } - - // Poll for task completion - while (true) { - // Get current task status - const task = await this.getTask({ taskId }, options); - yield { type: 'taskStatus', task }; - - // Check if task is terminal - if (isTerminal(task.status)) { - if (task.status === 'completed') { - // Get the final result - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - } else if (task.status === 'failed') { - yield { - type: 'error', - error: new McpError(ErrorCode.InternalError, `Task ${taskId} failed`) - }; - } else if (task.status === 'cancelled') { - yield { - type: 'error', - error: new McpError(ErrorCode.InternalError, `Task ${taskId} was cancelled`) - }; - } - return; - } - - // When input_required, call tasks/result to deliver queued messages - // (elicitation, sampling) via SSE and block until terminal - if (task.status === 'input_required') { - const result = await this.getTaskResult({ taskId }, resultSchema, options); - yield { type: 'result', result }; - return; - } - - // Wait before polling again - const pollInterval = task.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1000; - await new Promise(resolve => setTimeout(resolve, pollInterval)); - - // Check if cancelled - options?.signal?.throwIfAborted(); - } - } catch (error) { - yield { - type: 'error', - error: error instanceof McpError ? error : new McpError(ErrorCode.InternalError, String(error)) - }; - } - } - - /** - * Sends a request and waits for a response. - * - * Do not use this method to emit notifications! Use notification() instead. - */ - request(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise> { - const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; - - // 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); - - // If task creation is requested, also check task capabilities - if (task) { - this.assertTaskCapability(request.method); - } - } catch (e) { - earlyReject(e); - return; - } - } - - options?.signal?.throwIfAborted(); - - const messageId = this._requestMessageId++; - 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 - } - }; - } - - // Augment with task creation parameters if provided - if (task) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - task: task - }; - } - - // Augment with related task metadata if relatedTask is provided - if (relatedTask) { - jsonrpcRequest.params = { - ...jsonrpcRequest.params, - _meta: { - ...(jsonrpcRequest.params?._meta || {}), - [RELATED_TASK_META_KEY]: relatedTask - } - }; - } - - const cancel = (reason: unknown) => { - this._responseHandlers.delete(messageId); - this._progressHandlers.delete(messageId); - this._cleanupTimeout(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 McpError if it isn't already - const error = reason instanceof McpError ? reason : new McpError(ErrorCode.RequestTimeout, String(reason)); - reject(error); - }; - - this._responseHandlers.set(messageId, response => { - if (options?.signal?.aborted) { - return; - } - - if (response instanceof Error) { - return reject(response); - } - - try { - const parseResult = safeParse(resultSchema, response.result); - if (!parseResult.success) { - // Type guard: if success is false, error is guaranteed to exist - reject(parseResult.error); - } else { - resolve(parseResult.data as SchemaOutput); - } - } catch (error) { - reject(error); - } - }); - - options?.signal?.addEventListener('abort', () => { - cancel(options?.signal?.reason); - }); - - const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); - - this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); - - // Queue request if related to a task - const relatedTaskId = relatedTask?.taskId; - if (relatedTaskId) { - // Store the response resolver for this request so responses can be routed back - const responseResolver = (response: JSONRPCResultResponse | Error) => { - const handler = this._responseHandlers.get(messageId); - if (handler) { - handler(response); - } else { - // Log error when resolver is missing, but don't fail - this._onerror(new Error(`Response handler missing for side-channeled request ${messageId}`)); - } - }; - this._requestResolvers.set(messageId, responseResolver); - - this._enqueueTaskMessage(relatedTaskId, { - type: 'request', - message: jsonrpcRequest, - timestamp: Date.now() - }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); - - // Don't send through transport - queued messages are delivered via tasks/result only - // This prevents duplicate delivery for bidirectional transports - } else { - // No related task - send through transport normally - this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); - } - }); - } - - /** - * Gets the current status of a task. - * - * @experimental Use `client.experimental.tasks.getTask()` to access this method. - */ - protected async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise { - // @ts-expect-error SendRequestT cannot directly contain GetTaskRequest, but we ensure all type instantiations contain it anyways - return this.request({ method: 'tasks/get', params }, GetTaskResultSchema, options); - } - - /** - * Retrieves the result of a completed task. - * - * @experimental Use `client.experimental.tasks.getTaskResult()` to access this method. - */ - protected async getTaskResult( - params: GetTaskPayloadRequest['params'], - resultSchema: T, - options?: RequestOptions - ): Promise> { - // @ts-expect-error SendRequestT cannot directly contain GetTaskPayloadRequest, but we ensure all type instantiations contain it anyways - return this.request({ method: 'tasks/result', params }, resultSchema, options); - } - - /** - * Lists tasks, optionally starting from a pagination cursor. - * - * @experimental Use `client.experimental.tasks.listTasks()` to access this method. - */ - protected async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise> { - // @ts-expect-error SendRequestT cannot directly contain ListTasksRequest, but we ensure all type instantiations contain it anyways - return this.request({ method: 'tasks/list', params }, ListTasksResultSchema, options); - } - - /** - * Cancels a specific task. - * - * @experimental Use `client.experimental.tasks.cancelTask()` to access this method. - */ - protected async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise> { - // @ts-expect-error SendRequestT cannot directly contain CancelTaskRequest, but we ensure all type instantiations contain it anyways - return this.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options); - } - - /** - * Emits a notification, which is a one-way message that does not expect a response. - */ - async notification(notification: SendNotificationT, options?: NotificationOptions): Promise { - if (!this._transport) { - throw new Error('Not connected'); - } - - this.assertNotificationCapability(notification.method); - - // Queue notification if related to a task - const relatedTaskId = options?.relatedTask?.taskId; - if (relatedTaskId) { - // Build the JSONRPC notification with metadata - 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() - }); - - // Don't send through transport - queued messages are delivered via tasks/result only - // This prevents duplicate delivery for bidirectional transports - 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; - } - - let jsonrpcNotification: JSONRPCNotification = { - ...notification, - jsonrpc: '2.0' - }; - - // Augment with related task metadata if relatedTask is provided - if (options?.relatedTask) { - jsonrpcNotification = { - ...jsonrpcNotification, - params: { - ...jsonrpcNotification.params, - _meta: { - ...(jsonrpcNotification.params?._meta || {}), - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - - // 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; - } - - let jsonrpcNotification: JSONRPCNotification = { - ...notification, - jsonrpc: '2.0' - }; - - // Augment with related task metadata if relatedTask is provided - if (options?.relatedTask) { - jsonrpcNotification = { - ...jsonrpcNotification, - params: { - ...jsonrpcNotification.params, - _meta: { - ...(jsonrpcNotification.params?._meta || {}), - [RELATED_TASK_META_KEY]: options.relatedTask - } - } - }; - } - - 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. - */ - setRequestHandler( - requestSchema: T, - handler: ( - request: SchemaOutput, - extra: RequestHandlerExtra - ) => SendResultT | Promise - ): void { - const method = getMethodLiteral(requestSchema); - this.assertRequestHandlerCapability(method); - - this._requestHandlers.set(method, (request, extra) => { - const parsed = parseWithCompat(requestSchema, request) as SchemaOutput; - return Promise.resolve(handler(parsed, extra)); - }); - } - - /** - * Removes the request handler for the given method. - */ - removeRequestHandler(method: 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: 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. - */ - setNotificationHandler( - notificationSchema: T, - handler: (notification: SchemaOutput) => void | Promise - ): void { - const method = getMethodLiteral(notificationSchema); - this._notificationHandlers.set(method, notification => { - const parsed = parseWithCompat(notificationSchema, notification) as SchemaOutput; - return Promise.resolve(handler(parsed)); - }); - } - - /** - * Removes the notification handler for the given method. - */ - removeNotificationHandler(method: string): void { - this._notificationHandlers.delete(method); - } - - /** - * Cleans up the progress handler associated with a task. - * This should be called when a task reaches a terminal status. - */ - private _cleanupTaskProgressHandler(taskId: string): void { - const progressToken = this._taskProgressTokens.get(taskId); - if (progressToken !== undefined) { - this._progressHandlers.delete(progressToken); - this._taskProgressTokens.delete(taskId); - } - } - - /** - * Enqueues a task-related message for side-channel delivery via tasks/result. - * @param taskId The task ID to associate the message with - * @param message The message to enqueue - * @param sessionId Optional session ID for binding the operation to a specific session - * @throws Error if taskStore is not configured or if enqueue fails (e.g., queue overflow) - * - * Note: If enqueue fails, it's the TaskMessageQueue implementation's responsibility to handle - * the error appropriately (e.g., by failing the task, logging, etc.). The Protocol layer - * simply propagates the error. - */ - private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise { - // Task message queues are only used when taskStore is configured - if (!this._taskStore || !this._taskMessageQueue) { - throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured'); - } - - const maxQueueSize = this._options?.maxTaskQueueSize; - await this._taskMessageQueue.enqueue(taskId, message, sessionId, maxQueueSize); - } - - /** - * Clears the message queue for a task and rejects any pending request resolvers. - * @param taskId The task ID whose queue should be cleared - * @param sessionId Optional session ID for binding the operation to a specific session - */ - private async _clearTaskQueue(taskId: string, sessionId?: string): Promise { - if (this._taskMessageQueue) { - // Reject any pending request resolvers - const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId); - for (const message of messages) { - if (message.type === 'request' && isJSONRPCRequest(message.message)) { - // Extract request ID from the message - const requestId = message.message.id as RequestId; - const resolver = this._requestResolvers.get(requestId); - if (resolver) { - resolver(new McpError(ErrorCode.InternalError, 'Task cancelled or completed')); - this._requestResolvers.delete(requestId); - } else { - // Log error when resolver is missing during cleanup for better observability - this._onerror(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`)); - } - } - } - } - } - - /** - * Waits for a task update (new messages or status change) with abort signal support. - * Uses polling to check for updates at the task's configured poll interval. - * @param taskId The task ID to wait for - * @param signal Abort signal to cancel the wait - * @returns Promise that resolves when an update occurs or rejects if aborted - */ - private async _waitForTaskUpdate(taskId: string, signal: AbortSignal): Promise { - // Get the task's poll interval, falling back to default - let interval = this._options?.defaultTaskPollInterval ?? 1000; - try { - const task = await this._taskStore?.getTask(taskId); - if (task?.pollInterval) { - interval = task.pollInterval; - } - } catch { - // Use default interval if task lookup fails - } - - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled')); - return; - } - - // Wait for the poll interval, then resolve so caller can check for updates - const timeoutId = setTimeout(resolve, interval); - - // Clean up timeout and reject if aborted - signal.addEventListener( - 'abort', - () => { - clearTimeout(timeoutId); - reject(new McpError(ErrorCode.InvalidRequest, 'Request cancelled')); - }, - { once: true } - ); - }); - } - - private requestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore { - const taskStore = this._taskStore; - if (!taskStore) { - throw new Error('No task store configured'); - } - - 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 McpError(ErrorCode.InvalidParams, 'Failed to retrieve task: Task not found'); - } - - return task; - }, - storeTaskResult: async (taskId, status, result) => { - await taskStore.storeTaskResult(taskId, status, result, sessionId); - - // Get updated task state and send notification - const task = await taskStore.getTask(taskId, sessionId); - if (task) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: task - }); - await this.notification(notification as SendNotificationT); - - if (isTerminal(task.status)) { - this._cleanupTaskProgressHandler(taskId); - // Don't clear queue here - it will be cleared after delivery via tasks/result - } - } - }, - getTaskResult: taskId => { - return taskStore.getTaskResult(taskId, sessionId); - }, - updateTaskStatus: async (taskId, status, statusMessage) => { - // Check if task exists - const task = await taskStore.getTask(taskId, sessionId); - if (!task) { - throw new McpError(ErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`); - } - - // Don't allow transitions from terminal states - if (isTerminal(task.status)) { - throw new McpError( - ErrorCode.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); - - // Get updated task state and send notification - const updatedTask = await taskStore.getTask(taskId, sessionId); - if (updatedTask) { - const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({ - method: 'notifications/tasks/status', - params: updatedTask - }); - await this.notification(notification as SendNotificationT); - - if (isTerminal(updatedTask.status)) { - this._cleanupTaskProgressHandler(taskId); - // Don't clear queue here - it will be cleared after delivery via tasks/result - } - } - }, - listTasks: cursor => { - return taskStore.listTasks(cursor, sessionId); - } - }; - } -} - -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]; - if (isPlainObject(baseValue) && isPlainObject(addValue)) { - result[k] = { ...(baseValue as Record), ...(addValue as Record) } as T[typeof k]; - } else { - result[k] = addValue as T[typeof k]; - } - } - return result; -} diff --git a/src/shared/stdio.ts b/src/shared/stdio.ts deleted file mode 100644 index fe14612bd..000000000 --- a/src/shared/stdio.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.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 { - if (!this._buffer) { - return null; - } - - 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); - return deserializeMessage(line); - } - - 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/src/shared/toolNameValidation.ts b/src/shared/toolNameValidation.ts deleted file mode 100644 index fa96afde0..000000000 --- a/src/shared/toolNameValidation.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * 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. - */ - -/** - * 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 - .split('') - .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/src/shared/transport.ts b/src/shared/transport.ts deleted file mode 100644 index f9b21bed3..000000000 --- a/src/shared/transport.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types.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: HeadersInit | 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; - - /** - * 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; - - /** - * 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; -}; -/** - * 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 Client, Server, or Protocol classes, as they will implicitly call 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 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; - - /** - * Callback for when a message (request or response) is received over the connection. - * - * Includes the requestInfo and authInfo if the transport is authenticated. - * - * The requestInfo can be used to get the original request information (headers, etc.) - */ - onmessage?: (message: T, extra?: MessageExtraInfo) => void; - - /** - * The session ID generated for this connection. - */ - sessionId?: string; - - /** - * Sets the protocol version used for the connection (called when the initialize response is received). - */ - setProtocolVersion?: (version: string) => void; -} diff --git a/src/spec.types.ts b/src/spec.types.ts deleted file mode 100644 index 07a1cceff..000000000 --- a/src/spec.types.ts +++ /dev/null @@ -1,2587 +0,0 @@ -/** - * 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: 35fa160caf287a9c48696e3ae452c0645c713669 - * - * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. - * To update this file, run: npm run fetch:spec-types - *//* 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"; - -/** - * 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 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?: TaskMetadata; -} -/** - * Common params for any request. - * - * @internal - */ -export interface RequestParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { - /** - * 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?: ProgressToken; - [key: string]: unknown; - }; -} - -/** @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 }; -} - -/** @internal */ -export interface NotificationParams { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** @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 }; -} - -/** - * @category Common Types - */ -export interface Result { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * @category Common Types - */ -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. - */ -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; - -// 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. - * - * @internal - */ -export interface URLElicitationRequiredError - extends Omit { - error: Error & { - code: typeof URL_ELICITATION_REQUIRED; - data: { - elicitations: ElicitRequestURLParams[]; - [key: string]: unknown; - }; - }; -} - -/* Empty result */ -/** - * A response that indicates success but carries no data. - * - * @category Common Types - */ -export type EmptyResult = Result; - -/* Cancellation */ -/** - * Parameters for a `notifications/cancelled` notification. - * - * @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 `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 `tasks/cancel` request instead of this notification. - * - * @category `notifications/cancelled` - */ -export interface CancelledNotification extends JSONRPCNotification { - method: "notifications/cancelled"; - params: CancelledNotificationParams; -} - -/* Initialization */ -/** - * Parameters for an `initialize` request. - * - * @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. - * - * @category `initialize` - */ -export interface InitializeRequest extends JSONRPCRequest { - method: "initialize"; - params: InitializeRequestParams; -} - -/** - * After receiving an initialize request from the client, the server sends this response. - * - * @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. - * - * 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?: string; -} - -/** - * This notification is sent from the client to the server after initialization has finished. - * - * @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]: object }; - /** - * Present if the client supports listing roots. - */ - roots?: { - /** - * Whether the client supports notifications for changes to the roots list. - */ - listChanged?: boolean; - }; - /** - * Present if the client supports sampling from an LLM. - */ - sampling?: { - /** - * Whether the client supports context inclusion via includeContext parameter. - * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). - */ - context?: object; - /** - * Whether the client supports tool use via tools and toolChoice parameters. - */ - tools?: object; - }; - /** - * Present if the client supports elicitation from the server. - */ - elicitation?: { form?: object; url?: object }; - - /** - * Present if the client supports task-augmented requests. - */ - tasks?: { - /** - * Whether this client supports tasks/list. - */ - list?: object; - /** - * Whether this client supports tasks/cancel. - */ - cancel?: object; - /** - * 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?: object; - }; - /** - * Task support for elicitation-related requests. - */ - elicitation?: { - /** - * Whether the client supports task-augmented elicitation/create requests. - */ - create?: object; - }; - }; - }; -} - -/** - * 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]: object }; - /** - * Present if the server supports sending log messages to the client. - */ - logging?: object; - /** - * Present if the server supports argument autocompletion suggestions. - */ - completions?: object; - /** - * Present if the server offers any prompt templates. - */ - prompts?: { - /** - * Whether this server supports notifications for changes to the prompt list. - */ - listChanged?: boolean; - }; - /** - * Present if the server offers any resources to read. - */ - 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. - */ - 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 tasks/list. - */ - list?: object; - /** - * Whether this server supports tasks/cancel. - */ - cancel?: object; - /** - * Specifies which request types can be augmented with tasks. - */ - requests?: { - /** - * Task support for tool-related requests. - */ - tools?: { - /** - * Whether the server supports task-augmented tools/call requests. - */ - call?: object; - }; - }; - }; -} - -/** - * 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 takes 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 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 { - 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. - * - * @category `ping` - */ -export interface PingRequest extends JSONRPCRequest { - method: "ping"; - params?: RequestParams; -} - -/* Progress notifications */ - -/** - * Parameters for a `notifications/progress` notification. - * - * @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. - * - * @category `notifications/progress` - */ -export interface ProgressNotification extends JSONRPCNotification { - method: "notifications/progress"; - params: ProgressNotificationParams; -} - -/* Pagination */ -/** - * Common parameters for paginated requests. - * - * @internal - */ -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. - * - * @category `resources/list` - */ -export interface ListResourcesRequest extends PaginatedRequest { - method: "resources/list"; -} - -/** - * The server's response to a resources/list request from the client. - * - * @category `resources/list` - */ -export interface ListResourcesResult extends PaginatedResult { - resources: Resource[]; -} - -/** - * Sent from the client to request a list of resource templates the server has. - * - * @category `resources/templates/list` - */ -export interface ListResourceTemplatesRequest extends PaginatedRequest { - method: "resources/templates/list"; -} - -/** - * The server's response to a resources/templates/list request from the client. - * - * @category `resources/templates/list` - */ -export interface ListResourceTemplatesResult extends PaginatedResult { - resourceTemplates: ResourceTemplate[]; -} - -/** - * Common parameters when working with resources. - * - * @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. - * - * @category `resources/read` - */ -export interface ReadResourceRequest extends JSONRPCRequest { - method: "resources/read"; - params: ReadResourceRequestParams; -} - -/** - * The server's response to a resources/read request from the client. - * - * @category `resources/read` - */ -export interface ReadResourceResult extends Result { - contents: (TextResourceContents | BlobResourceContents)[]; -} - -/** - * 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. - * - * @category `notifications/resources/list_changed` - */ -export interface ResourceListChangedNotification extends JSONRPCNotification { - method: "notifications/resources/list_changed"; - params?: NotificationParams; -} - -/** - * Parameters for a `resources/subscribe` request. - * - * @category `resources/subscribe` - */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface SubscribeRequestParams extends ResourceRequestParams {} - -/** - * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. - * - * @category `resources/subscribe` - */ -export interface SubscribeRequest extends JSONRPCRequest { - method: "resources/subscribe"; - params: SubscribeRequestParams; -} - -/** - * 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. - * - * @category `resources/unsubscribe` - */ -export interface UnsubscribeRequest extends JSONRPCRequest { - method: "resources/unsubscribe"; - params: UnsubscribeRequestParams; -} - -/** - * Parameters for a `notifications/resources/updated` notification. - * - * @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 resources/subscribe request. - * - * @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. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * @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; -} - -/** - * @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. - * - * @category `prompts/list` - */ -export interface ListPromptsRequest extends PaginatedRequest { - method: "prompts/list"; -} - -/** - * The server's response to a prompts/list request from the client. - * - * @category `prompts/list` - */ -export interface ListPromptsResult extends PaginatedResult { - prompts: Prompt[]; -} - -/** - * Parameters for a `prompts/get` request. - * - * @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. - * - * @category `prompts/get` - */ -export interface GetPromptRequest extends JSONRPCRequest { - method: "prompts/get"; - params: GetPromptRequestParams; -} - -/** - * The server's response to a prompts/get request from the client. - * - * @category `prompts/get` - */ -export interface GetPromptResult extends Result { - /** - * An optional description for the prompt. - */ - description?: string; - messages: PromptMessage[]; -} - -/** - * 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[]; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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 `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 `resources/list` requests. - * - * @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. - * - * @category Content - */ -export interface EmbeddedResource { - type: "resource"; - resource: TextResourceContents | BlobResourceContents; - - /** - * Optional annotations for the client. - */ - annotations?: Annotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} -/** - * 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. - * - * @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. - * - * @category `tools/list` - */ -export interface ListToolsRequest extends PaginatedRequest { - method: "tools/list"; -} - -/** - * The server's response to a tools/list request from the client. - * - * @category `tools/list` - */ -export interface ListToolsResult extends PaginatedResult { - tools: Tool[]; -} - -/** - * The server's response to a tool call. - * - * @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; -} - -/** - * Parameters for a `tools/call` request. - * - * @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. - * - * @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. - * - * @category `notifications/tools/list_changed` - */ -export interface ToolListChangedNotification extends JSONRPCNotification { - method: "notifications/tools/list_changed"; - params?: NotificationParams; -} - -/** - * Additional properties describing a 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. - * - * @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]: object }; - 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 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]: object }; - required?: string[]; - }; - - /** - * Optional additional tool information. - * - * Display name precedence order is: title, annotations.title, then name. - */ - annotations?: ToolAnnotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/* 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. - */ - ttl: number | null; - - /** - * Suggested polling interval in milliseconds. - */ - pollInterval?: number; -} - -/** - * A response to a task-augmented request. - * - * @category `tasks` - */ -export interface CreateTaskResult extends Result { - task: Task; -} - -/** - * 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 response to a tasks/get request. - * - * @category `tasks/get` - */ -export type GetTaskResult = Result & Task; - -/** - * 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 response to a tasks/result request. - * The structure matches the result type of the original request. - * For example, a tools/call task would return the CallToolResult structure. - * - * @category `tasks/result` - */ -export interface GetTaskPayloadResult extends Result { - [key: string]: unknown; -} - -/** - * 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 response to a tasks/cancel request. - * - * @category `tasks/cancel` - */ -export type CancelTaskResult = Result & Task; - -/** - * A request to retrieve a list of tasks. - * - * @category `tasks/list` - */ -export interface ListTasksRequest extends PaginatedRequest { - method: "tasks/list"; -} - -/** - * The response to a tasks/list request. - * - * @category `tasks/list` - */ -export interface ListTasksResult extends PaginatedResult { - tasks: Task[]; -} - -/** - * 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. - * - * @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 notifications/message. - */ - level: LoggingLevel; -} - -/** - * A request from the client to the server, to enable or adjust logging. - * - * @category `logging/setLevel` - */ -export interface SetLevelRequest extends JSONRPCRequest { - method: "logging/setLevel"; - params: SetLevelRequestParams; -} - -/** - * Parameters for a `notifications/message` notification. - * - * @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. - * - * @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. - * - * @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 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?: object; - /** - * 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?: Tool[]; - /** - * 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?: 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. - * - * @category `sampling/createMessage` - */ -export interface CreateMessageRequest extends JSONRPCRequest { - method: "sampling/createMessage"; - params: CreateMessageRequestParams; -} - -/** - * The client's response to a sampling/createMessage request from the server. - * 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. - * - * @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; -} - -/** - * Describes a message issued to or received from an LLM API. - * - * @category `sampling/createMessage` - */ -export interface SamplingMessage { - role: Role; - content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} -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. - * - * @category Content - */ -export interface TextContent { - type: "text"; - - /** - * The text content of the message. - */ - text: string; - - /** - * Optional annotations for the client. - */ - annotations?: Annotations; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * An image provided to or from an LLM. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * Audio provided to or from an LLM. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * A request from the assistant to call a tool. - * - * @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. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * The result of a tool use, provided by the user back to the assistant. - * - * @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 ToolUseContent. - */ - toolUseId: string; - - /** - * The unstructured result content of the tool use. - * - * This has the same format as 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 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. - * - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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. - * - * @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` - */ -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. - * - * @category `completion/complete` - */ -export interface CompleteRequest extends JSONRPCRequest { - method: "completion/complete"; - params: CompleteRequestParams; -} - -/** - * The server's response to a completion/complete request - * - * @category `completion/complete` - */ -export interface CompleteResult extends Result { - completion: { - /** - * An array of completion values. Must not exceed 100 items. - */ - 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 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. - * - * @category `roots/list` - */ -export interface ListRootsRequest extends JSONRPCRequest { - method: "roots/list"; - params?: RequestParams; -} - -/** - * The client's response to a roots/list request from the server. - * This result contains an array of Root objects, each representing a root directory - * or file that the server can operate on. - * - * @category `roots/list` - */ -export interface ListRootsResult extends Result { - roots: Root[]; -} - -/** - * Represents a root directory or file that the server can operate on. - * - * @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; - - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; -} - -/** - * 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 ListRootsRequest. - * - * @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. - * - * @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. - * - * @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. - * - * @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; - -/** - * @category `elicitation/create` - */ -export interface StringSchema { - type: "string"; - title?: string; - description?: string; - minLength?: number; - maxLength?: number; - format?: "email" | "uri" | "date" | "date-time"; - default?: string; -} - -/** - * @category `elicitation/create` - */ -export interface NumberSchema { - type: "number" | "integer"; - title?: string; - description?: string; - minimum?: number; - maximum?: number; - default?: number; -} - -/** - * @category `elicitation/create` - */ -export interface BooleanSchema { - type: "boolean"; - title?: string; - description?: string; - default?: boolean; -} - -/** - * Schema for single-selection enumeration without display titles for options. - * - * @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. - * - * @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. - * - * @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. - * - * @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 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 client's response to an elicitation request. - * - * @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 decline 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[] }; -} - -/** - * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. - * - * @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 - | ListToolsResult - | GetTaskResult - | GetTaskPayloadResult - | ListTasksResult - | CancelTaskResult; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index dc0c22353..000000000 --- a/src/types.ts +++ /dev/null @@ -1,2556 +0,0 @@ -import * as z from 'zod/v4'; -import { AuthInfo } from './server/auth/types.js'; - -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'; - -/** - * Utility types - */ -type ExpandRecursively = T extends object ? (T extends infer O ? { [K in keyof O]: ExpandRecursively } : never) : T; -/** - * Assert 'object' type schema. - * - * @internal - */ -const AssertObjectSchema = z.custom((v): v is object => v !== null && (typeof v === 'object' || typeof v === 'function')); -/** - * 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({ - /** - * 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()]).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() -}); - -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. - */ -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() -}); - -/** - * Checks if a value is a valid TaskAugmentedRequestParams. - * @param value - The value to check. - * - * @returns True if the value is a valid TaskAugmentedRequestParams, false otherwise. - */ -export const isTaskAugmentedRequestParams = (value: unknown): value is TaskAugmentedRequestParams => - TaskAugmentedRequestParamsSchema.safeParse(value).success; - -export const RequestSchema = z.object({ - method: z.string(), - params: BaseRequestParamsSchema.loose().optional() -}); - -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(); - -export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => JSONRPCRequestSchema.safeParse(value).success; - -/** - * A notification which does not expect a response. - */ -export const JSONRPCNotificationSchema = z - .object({ - jsonrpc: z.literal(JSONRPC_VERSION), - ...NotificationSchema.shape - }) - .strict(); - -export const isJSONRPCNotification = (value: unknown): value is JSONRPCNotification => JSONRPCNotificationSchema.safeParse(value).success; - -/** - * A successful (non-error) response to a request. - */ -export const JSONRPCResultResponseSchema = z - .object({ - jsonrpc: z.literal(JSONRPC_VERSION), - id: RequestIdSchema, - result: ResultSchema - }) - .strict(); - -export const isJSONRPCResultResponse = (value: unknown): value is JSONRPCResultResponse => - JSONRPCResultResponseSchema.safeParse(value).success; - -/** - * Error codes defined by the JSON-RPC specification. - */ -export enum ErrorCode { - // SDK error codes - ConnectionClosed = -32000, - RequestTimeout = -32001, - - // Standard JSON-RPC error codes - ParseError = -32700, - InvalidRequest = -32600, - MethodNotFound = -32601, - InvalidParams = -32602, - InternalError = -32603, - - // MCP-specific error codes - UrlElicitationRequired = -32042 -} - -/** - * 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 isJSONRPCErrorResponse = (value: unknown): value is JSONRPCErrorResponse => - JSONRPCErrorResponseSchema.safeParse(value).success; - -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 `initialize` request. - */ -export const CancelledNotificationSchema = NotificationSchema.extend({ - method: z.literal('notifications/cancelled'), - params: CancelledNotificationParamsSchema -}); - -/* Base Metadata */ -/** - * Icon schema for use in tools, prompts, resources, and 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() -}); - -/** - * 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 resources, tools, prompts, and 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() -}); - -const FormElicitationCapabilitySchema = z.intersection( - z.object({ - applyDefaults: z.boolean().optional() - }), - z.record(z.string(), z.unknown()) -); - -const ElicitationCapabilitySchema = z.preprocess( - value => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - if (Object.keys(value as Record).length === 0) { - return { form: {} }; - } - } - return value; - }, - z.intersection( - z.object({ - form: FormElicitationCapabilitySchema.optional(), - url: AssertObjectSchema.optional() - }), - z.record(z.string(), z.unknown()).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: AssertObjectSchema.optional(), - /** - * Present if the client supports cancelling tasks. - */ - cancel: AssertObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for sampling requests. - */ - sampling: z - .looseObject({ - createMessage: AssertObjectSchema.optional() - }) - .optional(), - /** - * Task support for elicitation requests. - */ - elicitation: z - .looseObject({ - create: AssertObjectSchema.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: AssertObjectSchema.optional(), - /** - * Present if the server supports cancelling tasks. - */ - cancel: AssertObjectSchema.optional(), - /** - * Capabilities for task creation on specific request types. - */ - requests: z - .looseObject({ - /** - * Task support for tool requests. - */ - tools: z - .looseObject({ - call: AssertObjectSchema.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(), AssertObjectSchema).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: AssertObjectSchema.optional(), - /** - * Present if the client supports tool use via tools and toolChoice parameters. - */ - tools: AssertObjectSchema.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() -}); - -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 -}); - -export const isInitializeRequest = (value: unknown): value is InitializeRequest => InitializeRequestSchema.safeParse(value).success; - -/** - * 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(), AssertObjectSchema).optional(), - /** - * Present if the server supports sending log messages to the client. - */ - logging: AssertObjectSchema.optional(), - /** - * Present if the server supports sending completions to the client. - */ - completions: AssertObjectSchema.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() -}); - -/** - * 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() -}); - -export const isInitializedNotification = (value: unknown): value is InitializedNotification => - InitializedNotificationSchema.safeParse(value).success; - -/* 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 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 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 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 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()), - - /** - * 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 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 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 `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 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 resources/updated notifications from the server. This should follow a previous resources/subscribe request. - */ -export const UnsubscribeRequestSchema = RequestSchema.extend({ - method: z.literal('resources/unsubscribe'), - params: UnsubscribeRequestParamsSchema -}); - -/** - * Parameters for a `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 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 prompts/list request from the client. - */ -export const ListPromptsResultSchema = PaginatedResultSchema.extend({ - prompts: z.array(PromptSchema) -}); - -/** - * Parameters for a `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() -}); - -/** - * An Audio 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 `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 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 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 the 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(), AssertObjectSchema).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(), AssertObjectSchema).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 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() -}); - -/** - * 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() -}); - -/** - * Callback type for list changed notifications. - */ -export type ListChangedCallback = (error: Error | null, items: T[] | null) => void; - -/** - * 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) -}); - -/** - * 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; -}; - -/* 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 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: AssertObjectSchema.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 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() - }) -}); - -/** - * Parameters for an `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 `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 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 decline 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() -}); - -/** - * @deprecated Use ResourceTemplateReferenceSchema instead - */ -export const ResourceReferenceSchema = ResourceTemplateReferenceSchema; - -/** - * 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 `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 -}); - -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); -} - -/** - * The server's response to a 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 -]); - -export class McpError extends Error { - constructor( - public readonly code: number, - message: string, - public readonly data?: unknown - ) { - super(`MCP error ${code}: ${message}`); - this.name = 'McpError'; - } - - /** - * Factory method to create the appropriate error type based on the error code and data - */ - static fromError(code: number, message: string, data?: unknown): McpError { - // Check for specific error types - if (code === ErrorCode.UrlElicitationRequired && data) { - const errorData = data as { elicitations?: unknown[] }; - if (errorData.elicitations) { - return new UrlElicitationRequiredError(errorData.elicitations as ElicitRequestURLParams[], message); - } - } - - // Default to generic McpError - return new McpError(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 McpError { - constructor(elicitations: ElicitRequestURLParams[], message: string = `URL elicitation${elicitations.length > 1 ? 's' : ''} required`) { - super(ErrorCode.UrlElicitationRequired, message, { - elicitations: elicitations - }); - } - - get elicitations(): ElicitRequestURLParams[] { - return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? []; - } -} - -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>; - -/** - * Headers that are compatible with both Node.js and the browser. - */ -export type IsomorphicHeaders = Record; - -/** - * Information about the incoming request. - */ -export interface RequestInfo { - /** - * The headers of the request. - */ - headers: IsomorphicHeaders; -} - -/** - * Extra information about a message. - */ -export interface MessageExtraInfo { - /** - * The request information. - */ - requestInfo?: RequestInfo; - - /** - * The authentication information. - */ - authInfo?: AuthInfo; - - /** - * Callback to close the SSE stream for this request, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. - */ - closeSSEStream?: () => void; - - /** - * Callback to close the standalone GET SSE stream, triggering client reconnection. - * Only available when using StreamableHTTPServerTransport with eventStore configured. - */ - closeStandaloneSSEStream?: () => void; -} - -/* 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; -export type ResourceTemplate = 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; - -/** - * CreateMessageRequestParams without tools - for backwards-compatible overload. - * Excludes tools/toolChoice to indicate they should not be provided. - */ -export type CreateMessageRequestParamsBase = Omit; - -/** - * CreateMessageRequestParams with required tools - for tool-enabled overload. - */ -export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { - tools: Tool[]; -} - -/* 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; -/** - * @deprecated Use ResourceTemplateReference instead - */ -export type ResourceReference = ResourceTemplateReference; -export type PromptReference = Infer; -export type CompleteRequestParams = Infer; -export type CompleteRequest = Infer; -export type CompleteRequestResourceTemplate = ExpandRecursively< - CompleteRequest & { params: CompleteRequestParams & { ref: ResourceTemplateReference } } ->; -export type CompleteRequestPrompt = ExpandRecursively; -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; diff --git a/src/validation/ajv-provider.ts b/src/validation/ajv-provider.ts deleted file mode 100644 index 115a98521..000000000 --- a/src/validation/ajv-provider.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * AJV-based JSON Schema validator provider - */ - -import { Ajv } from 'ajv'; -import _addFormats from 'ajv-formats'; -import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } 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 - * ```typescript - * // Use with default AJV instance (recommended) - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; - * const validator = new AjvJsonSchemaValidator(); - * - * // Use with custom AJV instance - * import { Ajv } from 'ajv'; - * const ajv = new Ajv({ strict: true, allErrors: true }); - * const validator = new AjvJsonSchemaValidator(ajv); - * ``` - */ -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 - * ```typescript - * // Use default configuration (recommended for most cases) - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; - * const validator = new AjvJsonSchemaValidator(); - * - * // Or provide custom AJV instance for advanced configuration - * import { Ajv } from 'ajv'; - * import addFormats from 'ajv-formats'; - * - * 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); - - if (valid) { - return { - valid: true, - data: input as T, - errorMessage: undefined - }; - } else { - return { - valid: false, - data: undefined, - errorMessage: this._ajv.errorsText(ajvValidator.errors) - }; - } - }; - } -} diff --git a/src/validation/cfworker-provider.ts b/src/validation/cfworker-provider.ts deleted file mode 100644 index 7e6329d9d..000000000 --- a/src/validation/cfworker-provider.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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. - */ - -import { Validator } from '@cfworker/json-schema'; -import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; - -/** - * JSON Schema draft version supported by @cfworker/json-schema - */ -export type CfWorkerSchemaDraft = '4' | '7' | '2019-09' | '2020-12'; - -/** - * - * @example - * ```typescript - * // Use with default configuration (2020-12, shortcircuit) - * const validator = new CfWorkerJsonSchemaValidator(); - * - * // Use with custom configuration - * 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); - - if (result.valid) { - return { - valid: true, - data: input as T, - errorMessage: undefined - }; - } else { - return { - valid: false, - data: undefined, - errorMessage: result.errors.map(err => `${err.instanceLocation}: ${err.error}`).join('; ') - }; - } - }; - } -} diff --git a/src/validation/index.ts b/src/validation/index.ts deleted file mode 100644 index a6df86d6a..000000000 --- a/src/validation/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * JSON Schema validation - * - * This module provides configurable JSON Schema validation for the MCP SDK. - * Choose a validator based on your runtime environment: - * - * - AjvJsonSchemaValidator: Best for Node.js (default, fastest) - * Import from: @modelcontextprotocol/sdk/validation/ajv - * Requires peer dependencies: ajv, ajv-formats - * - * - CfWorkerJsonSchemaValidator: Best for edge runtimes - * Import from: @modelcontextprotocol/sdk/validation/cfworker - * Requires peer dependency: @cfworker/json-schema - * - * @example - * ```typescript - * // For Node.js with AJV - * import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; - * const validator = new AjvJsonSchemaValidator(); - * - * // For Cloudflare Workers - * import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker'; - * const validator = new CfWorkerJsonSchemaValidator(); - * ``` - * - * @module validation - */ - -// Core types only - implementations are exported via separate entry points -export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; diff --git a/src/validation/types.ts b/src/validation/types.ts deleted file mode 100644 index 5864a43f2..000000000 --- a/src/validation/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -// 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 - * ```typescript - * class MyValidatorProvider implements jsonSchemaValidator { - * getValidator(schema: JsonSchemaType): JsonSchemaValidator { - * // Compile/cache validator from schema - * return (input: unknown) => { - * // Validate input against schema - * if (valid) { - * return { valid: true, data: input as T, errorMessage: undefined }; - * } else { - * return { 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/test/client/auth-extensions.test.ts b/test/client/auth-extensions.test.ts deleted file mode 100644 index a7217307d..000000000 --- a/test/client/auth-extensions.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { auth } from '../../src/client/auth.js'; -import { - ClientCredentialsProvider, - PrivateKeyJwtProvider, - StaticPrivateKeyJwtProvider, - createPrivateKeyJwtAuth -} from '../../src/client/auth-extensions.js'; -import { createMockOAuthFetch } from '../helpers/oauth.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('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('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'); - }); -}); - -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 add 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( - /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/ - ); - }); -}); diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts deleted file mode 100644 index d6e7e8684..000000000 --- a/test/client/auth.test.ts +++ /dev/null @@ -1,3247 +0,0 @@ -import { LATEST_PROTOCOL_VERSION } from '../../src/types.js'; -import { - discoverOAuthMetadata, - discoverAuthorizationServerMetadata, - buildDiscoveryUrls, - startAuthorization, - exchangeAuthorization, - refreshAuthorization, - registerClient, - discoverOAuthProtectedResourceMetadata, - extractWWWAuthenticateParams, - auth, - type OAuthClientProvider, - selectClientAuthMethod, - isHttpsUrl -} from '../../src/client/auth.js'; -import { createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; -import { InvalidClientMetadataError, ServerError } from '../../src/server/auth/errors.js'; -import { AuthorizationServerMetadata, OAuthTokens } from '../../src/shared/auth.js'; -import { expect, vi, type Mock } from 'vitest'; - -// Mock pkce-challenge -vi.mock('pkce-challenge', () => ({ - default: () => ({ - code_verifier: 'test_verifier', - code_challenge: 'test_challenge' - }) -})); - -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - -describe('OAuth Authorization', () => { - beforeEach(() => { - mockFetch.mockReset(); - }); - - 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', async () => { - // Set up a counter to control behavior - let callCount = 0; - - // Mock implementation that changes behavior based on call count - mockFetch.mockImplementation((_url, _options) => { - callCount++; - - if (callCount === 1) { - // First call with MCP header - fail with TypeError (simulating CORS error) - // We need to use TypeError specifically because that's what the implementation checks for - return Promise.reject(new TypeError('Network error')); - } else { - // Second call without header - succeed - return 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', async () => { - // Set up a counter to control behavior - let callCount = 0; - - // Mock implementation that changes behavior based on call count - mockFetch.mockImplementation((_url, _options) => { - callCount++; - - if (callCount === 1) { - // First call - fail with TypeError - return Promise.reject(new TypeError('First failure')); - } else { - // Second call - fail with different error - return 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 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 error on 500 status and does not fallback', async () => { - // First call (path-aware) returns 500 - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500 - }); - - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow(); - - const calls = mockFetch.mock.calls; - expect(calls.length).toBe(1); // Should not attempt fallback - }); - - 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', async () => { - // 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', async () => { - // 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', async () => { - // Set up a counter to control behavior - let callCount = 0; - - // Mock implementation that changes behavior based on call count - mockFetch.mockImplementation((_url, _options) => { - callCount++; - - if (callCount === 1) { - // First call with MCP header - fail with TypeError (simulating CORS error) - // We need to use TypeError specifically because that's what the implementation checks for - return Promise.reject(new TypeError('Network error')); - } else { - // Second call without header - succeed - return 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', async () => { - // Set up a counter to control behavior - let callCount = 0; - - // Mock implementation that changes behavior based on call count - mockFetch.mockImplementation((_url, _options) => { - callCount++; - - if (callCount === 1) { - // First call - fail with TypeError - return Promise.reject(new TypeError('First failure')); - } else { - // Second call - fail with different error - return 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', async () => { - // 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', async () => { - mockFetch.mockResolvedValueOnce(new Response(null, { status: 500 })); - - await expect(discoverOAuthMetadata('https://auth.example.com')).rejects.toThrow('HTTP 500'); - }); - - 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('throws on non-4xx errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500 - }); - - await expect(discoverAuthorizationServerMetadata('https://mcp.example.com')).rejects.toThrow('HTTP 500'); - }); - - it('handles CORS errors with retry', async () => { - // 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', async () => { - // 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) - }); - }); - - 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'); - }); - }); - - 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'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('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'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('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 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'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('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'); - expect(body.get('client_id')).toBe('client123'); - expect(body.get('client_secret')).toBe('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 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: 1612137600, - client_secret_expires_at: 1612224000, - ...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('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 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() { - 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: 1612137600, - client_secret_expires_at: 1612224000, - 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: 1612137600, - client_secret_expires_at: 1612224000, - 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'); - }); - - 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_post when no auth methods specified', 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]; - - // Check headers - expect(request.headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); - 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'); - }); - }); - - 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('../../src/shared/transport.js'); - - 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('../../src/shared/transport.js'); - - 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('../../src/shared/transport.js'); - - 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.toThrow(InvalidClientMetadataError); - }); - - 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.toThrow(InvalidClientMetadataError); - }); - - 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.toThrow(InvalidClientMetadataError); - }); - - 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'] - }); - }); - }); -}); diff --git a/test/client/cross-spawn.test.ts b/test/client/cross-spawn.test.ts deleted file mode 100644 index 26ae682fe..000000000 --- a/test/client/cross-spawn.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/stdio.js'; -import spawn from 'cross-spawn'; -import { JSONRPCMessage } from '../../src/types.js'; -import { ChildProcess } from 'node:child_process'; -import { Mock, MockedFunction } from 'vitest'; - -// 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(); - }); -}); diff --git a/test/client/index.test.ts b/test/client/index.test.ts deleted file mode 100644 index 9735eb2ba..000000000 --- a/test/client/index.test.ts +++ /dev/null @@ -1,4139 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-constant-binary-expression */ -/* eslint-disable @typescript-eslint/no-unused-expressions */ -import { Client, getSupportedElicitationModes } from '../../src/client/index.js'; -import { - RequestSchema, - NotificationSchema, - ResultSchema, - LATEST_PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, - InitializeRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ListToolsResultSchema, - ListPromptsRequestSchema, - CallToolRequestSchema, - CallToolResultSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ElicitResultSchema, - ListRootsRequestSchema, - ErrorCode, - McpError, - CreateTaskResultSchema, - Tool, - Prompt, - Resource -} from '../../src/types.js'; -import { Transport } from '../../src/shared/transport.js'; -import { Server } from '../../src/server/index.js'; -import { McpServer } from '../../src/server/mcp.js'; -import { InMemoryTransport } from '../../src/inMemory.js'; -import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js'; -import * as z3 from 'zod/v3'; -import * as z4 from 'zod/v4'; - -describe('Zod v4', () => { - /*** - * Test: Type Checking - * Test that custom request/notification/result schemas can be used with the Client class. - */ - test('should typecheck', () => { - const GetWeatherRequestSchema = RequestSchema.extend({ - method: z4.literal('weather/get'), - params: z4.object({ - city: z4.string() - }) - }); - - const GetForecastRequestSchema = RequestSchema.extend({ - method: z4.literal('weather/forecast'), - params: z4.object({ - city: z4.string(), - days: z4.number() - }) - }); - - const WeatherForecastNotificationSchema = NotificationSchema.extend({ - method: z4.literal('weather/alert'), - params: z4.object({ - severity: z4.enum(['warning', 'watch']), - message: z4.string() - }) - }); - - const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); - const WeatherNotificationSchema = WeatherForecastNotificationSchema; - const WeatherResultSchema = ResultSchema.extend({ - temperature: z4.number(), - conditions: z4.string() - }); - - type WeatherRequest = z4.infer; - type WeatherNotification = z4.infer; - type WeatherResult = z4.infer; - - // Create a typed Client for weather data - const weatherClient = new Client( - { - name: 'WeatherClient', - version: '1.0.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - // Typecheck that only valid weather requests/notifications/results are allowed - false && - weatherClient.request( - { - method: 'weather/get', - params: { - city: 'Seattle' - } - }, - WeatherResultSchema - ); - - false && - weatherClient.notification({ - method: 'weather/alert', - params: { - severity: 'warning', - message: 'Storm approaching' - } - }); - }); -}); - -describe('Zod v3', () => { - /*** - * Test: Type Checking - * Test that custom request/notification/result schemas can be used with the Client class. - */ - test('should typecheck', () => { - const GetWeatherRequestSchema = z3.object({ - ...RequestSchema.shape, - method: z3.literal('weather/get'), - params: z3.object({ - city: z3.string() - }) - }); - - const GetForecastRequestSchema = z3.object({ - ...RequestSchema.shape, - method: z3.literal('weather/forecast'), - params: z3.object({ - city: z3.string(), - days: z3.number() - }) - }); - - const WeatherForecastNotificationSchema = z3.object({ - ...NotificationSchema.shape, - method: z3.literal('weather/alert'), - params: z3.object({ - severity: z3.enum(['warning', 'watch']), - message: z3.string() - }) - }); - - const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); - const WeatherNotificationSchema = WeatherForecastNotificationSchema; - const WeatherResultSchema = z3.object({ - ...ResultSchema.shape, - _meta: z3.record(z3.string(), z3.unknown()).optional(), - temperature: z3.number(), - conditions: z3.string() - }); - - type WeatherRequest = z3.infer; - type WeatherNotification = z3.infer; - type WeatherResult = z3.infer; - - // Create a typed Client for weather data - const weatherClient = new Client( - { - name: 'WeatherClient', - version: '1.0.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - // Typecheck that only valid weather requests/notifications/results are allowed - false && - weatherClient.request( - { - method: 'weather/get', - params: { - city: 'Seattle' - } - }, - WeatherResultSchema - ); - - false && - weatherClient.notification({ - method: 'weather/alert', - params: { - severity: 'warning', - message: 'Storm approaching' - } - }); - }); -}); - -/*** - * 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: 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(InitializeRequestSchema, _request => ({ - protocolVersion: OLD_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'old server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - 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 OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const server = new Server( - { - name: 'new server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'new server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - 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 OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const FUTURE_VERSION = 'FUTURE_VERSION'; - const server = new Server( - { - name: 'new server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: FUTURE_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'new server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - 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(InitializeRequestSchema, _request => ({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'test', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - 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: 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(CreateMessageRequestSchema, () => ({ - 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(ListRootsRequestSchema, () => ({})); - }).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(ElicitRequestSchema, () => ({ - action: 'accept', - content: { - username: 'test-user', - confirmed: true - } - })); - }).not.toThrow(); - - // This should throw because sampling is not a declared capability - expect(() => { - client.setRequestHandler(CreateMessageRequestSchema, () => ({ - 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(ElicitRequestSchema, 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(ElicitRequestSchema, 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(ErrorCode.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(ElicitRequestSchema, 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' - } - } - } - } - }, - ElicitResultSchema - ) - ).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(ElicitRequestSchema, 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(ErrorCode.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(ElicitRequestSchema, 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(ListResourcesRequestSchema, async (request, extra) => { - 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 McpError - await expect(listResourcesPromise).rejects.toThrow(McpError); -}); - -/*** - * 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(ListResourcesRequestSchema, async (_request, extra) => { - const timer = new Promise(resolve => { - const timeout = setTimeout(resolve, 100); - extra.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: ErrorCode.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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { tools: {} }, // No listChanged: true - serverInfo: { name: 'test-server', version: '1.0.0' } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { tools: { listChanged: true } }, - serverInfo: { name: 'test-server', version: '1.0.0' } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(InitializeRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: { tools: { listChanged: true }, prompts: {} }, - serverInfo: { name: 'test-server', version: '1.0.0' } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [{ name: 'tool-1', inputSchema: { type: 'object' } }] - })); - - server.setRequestHandler(ListPromptsRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - } - // No outputSchema - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, 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(CallToolRequestSchema, 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' - }); - - 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: {} - }, - { - async createTask(_args, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Tool executed successfully!' }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - 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: {} }, CallToolResultSchema, { - task: { - ttl: 60000 - } - }); - - // 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: {} - }, - { - async createTask(_args, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Create a task - await client.callTool({ name: 'test-tool', arguments: {} }, CallToolResultSchema, { - task: { ttl: 60000 } - }); - - // 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: {} - }, - { - async createTask(_args, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Result data!' }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - 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: {} }, CallToolResultSchema, { - task: { ttl: 60000 } - }); - - 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: {} - }, - { - async createTask(_args, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - 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: {} }, CallToolResultSchema, { - task: { ttl: 60000 } - }); - - // 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(ElicitRequestSchema, async (request, extra) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && extra.taskStore) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - await extra.taskStore.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'] - } - } - }, - CreateTaskResultSchema, - { task: { ttl: 60000 } } - ); - - // 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(ElicitRequestSchema, async (request, extra) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && extra.taskStore) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - await extra.taskStore.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' } } - } - } - }, - CreateTaskResultSchema, - { task: { ttl: 60000 } } - ); - - // 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(ElicitRequestSchema, async (request, extra) => { - const result = { - action: 'accept', - content: { username: 'result-user' } - }; - - // Check if task creation is requested - if (request.params.task && extra.taskStore) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - await extra.taskStore.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' } } - } - } - }, - CreateTaskResultSchema, - { task: { ttl: 60000 } } - ); - - // 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(ElicitRequestSchema, async (request, extra) => { - const result = { - action: 'accept', - content: { username: 'list-user' } - }; - - // Check if task creation is requested - if (request.params.task && extra.taskStore) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - await extra.taskStore.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' } } - } - } - }, - CreateTaskResultSchema, - { task: { ttl: 60000 } } - ); - - // 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: { - id: z4.string() - } - }, - { - async createTask({ id }, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: `Result for ${id || 'unknown'}` }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - 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}` } }, CallToolResultSchema, { - task: { ttl: 60000 } - }); - - // 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(ElicitRequestSchema, 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: {} - }, - { - async createTask(_args, extra) { - const task = await extra.taskStore.createTask({ - ttl: extra.taskRequestedTtl - }); - - const result = { - content: [{ type: 'text', text: 'Success!' }] - }; - await extra.taskStore.storeTaskResult(task.taskId, 'completed', result); - - return { task }; - }, - async getTask(_args, extra) { - const task = await extra.taskStore.getTask(extra.taskId); - if (!task) { - throw new Error(`Task ${extra.taskId} not found`); - } - return task; - }, - async getTaskResult(_args, extra) { - const result = await extra.taskStore.getTaskResult(extra.taskId); - return result as { content: Array<{ type: 'text'; text: string }> }; - } - } - ); - - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - enforceStrictCapabilities: true - } - ); - - 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: {} }, CallToolResultSchema, { - task: { ttl: 60000 } - }) - ).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: {} - }, - ListToolsResultSchema - ) - ).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(CallToolRequestSchema, 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: {} - } - ); - - 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: {} } - }, - CallToolResultSchema - ); - - 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(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, 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(CallToolRequestSchema, 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: {} - } - ); - - 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(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, 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: {} - } - ); - - 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); - }); -}); diff --git a/test/client/middleware.test.ts b/test/client/middleware.test.ts deleted file mode 100644 index 06bda69c8..000000000 --- a/test/client/middleware.test.ts +++ /dev/null @@ -1,1118 +0,0 @@ -import { withOAuth, withLogging, applyMiddlewares, createMiddleware } from '../../src/client/middleware.js'; -import { OAuthClientProvider } from '../../src/client/auth.js'; -import { FetchLike } from '../../src/shared/transport.js'; -import { MockInstance, Mocked, MockedFunction } from 'vitest'; - -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(); - const transformed = { ...data, timestamp: 123456789 }; - - return new Response(JSON.stringify(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: 123456789 - }); - }); - - 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/test/client/sse.test.ts b/test/client/sse.test.ts deleted file mode 100644 index 6574b60b8..000000000 --- a/test/client/sse.test.ts +++ /dev/null @@ -1,1506 +0,0 @@ -import { createServer, ServerResponse, type IncomingMessage, type Server } from 'node:http'; -import { JSONRPCMessage } from '../../src/types.js'; -import { SSEClientTransport } from '../../src/client/sse.js'; -import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; -import { OAuthTokens } from '../../src/shared/auth.js'; -import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; -import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; -import { listenOnRandomPort } from '../helpers/http.js'; -import { AddressInfo } from 'node:net'; - -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 = global.fetch; - try { - global.fetch = vi.fn().mockResolvedValue({ ok: true }); - - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {} - }; - - await transport.send(message); - - const calledHeaders = (global.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 = (global.fetch as Mock).mock.calls[1][1].headers; - expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); - } finally { - global.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 = global.fetch; - try { - global.fetch = vi.fn().mockResolvedValue({ ok: true }); - - const message: JSONRPCMessage = { - jsonrpc: '2.0', - id: '1', - method: 'test', - params: {} - }; - - await transport.send(message); - - const calledHeaders = (global.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 = (global.fetch as Mock).mock.calls[1][1].headers; - expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); - } finally { - global.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 = global.fetch; - try { - global.fetch = vi.fn().mockResolvedValue({ ok: true }); - - await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); - - const calledHeaders = (global.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 { - global.fetch = originalFetch; - } - }); - }); - - describe('auth handling', () => { - const authServerMetadataUrls = ['/.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(404).end(); - } else { - res.writeHead(401).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.includes(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); - if ( - params.get('grant_type') === 'refresh_token' && - params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === '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.includes(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); - if ( - params.get('grant_type') === 'refresh_token' && - params.get('refresh_token') === 'refresh-token' && - params.get('client_id') === 'test-client-id' && - params.get('client_secret') === '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.includes(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 InvalidClientError 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' - }); - - let baseUrl = resourceBaseUrl; - - // Create server that returns InvalidClientError 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') { - // Handle token refresh request - return InvalidClientError - const error = new InvalidClientError('Client authentication failed'); - res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(InvalidClientError); - expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); - }); - - it('invalidates all credentials on UnauthorizedClientError 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' - }); - - 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') { - // Handle token refresh request - return UnauthorizedClientError - const error = new UnauthorizedClientError('Client not authorized'); - res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(UnauthorizedClientError); - expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); - }); - - it('invalidates tokens on InvalidGrantError 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' - }); - 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') { - // Handle token refresh request - return InvalidGrantError - const error = new InvalidGrantError('Invalid refresh token'); - res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.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.toThrow(InvalidGrantError); - 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); - if ( - params.get('grant_type') === 'authorization_code' && - params.get('code') === 'test-auth-code' && - params.get('client_id') === 'test-client-id' - ) { - 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(global, '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(); - }); - }); -}); diff --git a/test/client/stdio.test.ts b/test/client/stdio.test.ts deleted file mode 100644 index 52a871ee1..000000000 --- a/test/client/stdio.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { JSONRPCMessage } from '../../src/types.js'; -import { StdioClientTransport, StdioServerParameters } 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/test/client/streamableHttp.test.ts b/test/client/streamableHttp.test.ts deleted file mode 100644 index 52c8f1074..000000000 --- a/test/client/streamableHttp.test.ts +++ /dev/null @@ -1,1626 +0,0 @@ -import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnectionOptions } from '../../src/client/streamableHttp.js'; -import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; -import { JSONRPCMessage, JSONRPCRequest } from '../../src/types.js'; -import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; -import { type Mock, type Mocked } from 'vitest'; - -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(global, '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' - }; - - (global.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 202, - headers: new Headers() - }); - - await transport.send(message); - - expect(global.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' } - ]; - - (global.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 200, - headers: new Headers({ 'content-type': 'text/event-stream' }), - body: null - }); - - await transport.send(messages); - - expect(global.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' - }; - - (global.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 - (global.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 = (global.fetch as Mock).mock.calls; - const lastCall = calls[calls.length - 1]; - expect(lastCall[1].headers).toBeDefined(); - expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); - }); - - 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' - }; - - (global.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 - (global.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 = (global.fetch as Mock).mock.calls; - const lastCall = calls[calls.length - 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' - }; - - (global.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 - (global.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' - }; - - (global.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('Streamable HTTP error: Error POSTing to endpoint: 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' - }; - - (global.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) - (global.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(global.fetch).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - headers: expect.any(Headers) - }) - ); - - // Verify transport still works after 405 - (global.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 202, - headers: new Headers() - }); - - await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); - expect(global.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 - (global.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)); - } - }); - }; - - (global.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: 10000, - 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 = global.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 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; - - (global.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(global.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 = {}; - - (global.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(global.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 = {}; - - (global.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(global.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 = {}; - - (global.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 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' - }; - - (global.fetch as Mock) - .mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - headers: new Headers(), - text: async () => Promise.reject('dont read my body') - }) - .mockResolvedValue({ - ok: false, - status: 404, - text: async () => Promise.reject('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 = global.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 = global.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 = global.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 = global.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 = global.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 = global.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 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 = global.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 = global.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 InvalidClientError 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 () => Promise.reject('dont read my body') - }; - (global.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 InvalidClientError - .mockResolvedValueOnce( - Response.json(new InvalidClientError('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 UnauthorizedClientError 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 () => Promise.reject('dont read my body') - }; - (global.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 UnauthorizedClientError - .mockResolvedValueOnce(Response.json(new UnauthorizedClientError('Client not authorized').toResponseObject(), { status: 400 })) - // Fallback should fail to complete the flow - .mockResolvedValue({ - ok: false, - status: 404, - text: async () => Promise.reject('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 InvalidGrantError 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 () => Promise.reject('dont read my body') - }; - (global.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 InvalidGrantError - .mockResolvedValueOnce(Response.json(new InvalidGrantError('Invalid refresh token').toResponseObject(), { status: 400 })) - // Fallback should fail to complete the flow - .mockResolvedValue({ - ok: false, - status: 404, - text: async () => Promise.reject('dont read my body') - }); - - // Behavior for InvalidGrantError 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 () => Promise.reject('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 InvalidClientError - .mockResolvedValueOnce( - Response.json(new InvalidClientError('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(global.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(global.fetch).not.toHaveBeenCalled(); - }); - }); - - describe('SSE retry field handling', () => { - beforeEach(() => { - vi.useFakeTimers(); - (global.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 = global.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 = global.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 timeout was scheduled (no reconnection attempt) - expect(transport['_reconnectionTimeout']).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['_reconnectionTimeout']).toBeDefined(); - - // Clean up the timeout to avoid test pollution - clearTimeout(transport['_reconnectionTimeout']); - }); - }); - - 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 () => Promise.reject('dont read my body') - }; - - (global.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); - - await expect(transport.send(message)).rejects.toThrow('Server returned 401 after successful authentication'); - expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ - access_token: 'new-access-token', - token_type: 'Bearer', - expires_in: 3600, - refresh_token: 'refresh-token' // Refresh token is preserved - }); - }); - }); -}); diff --git a/test/experimental/tasks/stores/in-memory.test.ts b/test/experimental/tasks/stores/in-memory.test.ts deleted file mode 100644 index ceef6c6d8..000000000 --- a/test/experimental/tasks/stores/in-memory.test.ts +++ /dev/null @@ -1,936 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../../../../src/experimental/tasks/stores/in-memory.js'; -import { TaskCreationParams, Request } from '../../../../src/types.js'; -import { QueuedMessage } from '../../../../src/experimental/tasks/interfaces.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: 60000 - }; - 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(60000); - 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: 60000 - }; - 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).toEqual(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).toEqual(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).toEqual(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(100000); - - // 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 null TTL for unlimited lifetime', async () => { - // Test that null TTL means unlimited lifetime - const taskParams: TaskCreationParams = { - ttl: null - }; - const createdTask = await store.createTask(taskParams, 2222, { - method: 'tools/call', - params: {} - }); - - // The returned task should have null TTL - expect(createdTask.ttl).toBeNull(); - - // Task should not be cleaned up even after a long time - vi.advanceTimersByTime(100000); - 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).toEqual([]); - }); - }); - - describe('listTasks', () => { - it('should return empty list when no tasks', async () => { - const result = await store.listTasks(); - expect(result.tasks).toEqual([]); - 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 = Array.from(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('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).toEqual(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).toEqual(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).toEqual(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')).toEqual(request); - expect(await queue.dequeue('task-fifo')).toEqual(notification); - expect(await queue.dequeue('task-fifo')).toEqual(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]).toEqual(request); - expect(all[1]).toEqual(response); - expect(all[2]).toEqual(notification); - }); - - it('should return empty array for non-existent task', async () => { - const all = await queue.dequeueAll('non-existent'); - expect(all).toEqual([]); - }); - - 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')).toEqual(message1); - expect(await queue.dequeue('task-b')).toEqual(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: -32600, - message: 'Invalid Request' - } - }, - timestamp: Date.now() - }; - - await queue.enqueue('task-error', errorResponse); - const dequeued = await queue.dequeue('task-error'); - - expect(dequeued).toEqual(errorResponse); - expect(dequeued?.type).toBe('error'); - }); - }); -}); diff --git a/test/experimental/tasks/task-listing.test.ts b/test/experimental/tasks/task-listing.test.ts deleted file mode 100644 index bf51f1404..000000000 --- a/test/experimental/tasks/task-listing.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { ErrorCode, McpError } from '../../../src/types.js'; -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: McpError) => { - expect(error).toBeInstanceOf(McpError); - expect(error.code).toBe(ErrorCode.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/shared/auth-utils.test.ts b/test/shared/auth-utils.test.ts deleted file mode 100644 index b3b13a2f6..000000000 --- a/test/shared/auth-utils.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { resourceUrlFromServerUrl, checkResourceAllowed } from '../../src/shared/auth-utils.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/test/shared/auth.test.ts b/test/shared/auth.test.ts deleted file mode 100644 index c4ecab59d..000000000 --- a/test/shared/auth.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - SafeUrlSchema, - OAuthMetadataSchema, - OpenIdProviderMetadataSchema, - OAuthClientMetadataSchema, - OptionalSafeUrlSchema -} 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/test/shared/protocol-transport-handling.test.ts b/test/shared/protocol-transport-handling.test.ts deleted file mode 100644 index 60eff5c2e..000000000 --- a/test/shared/protocol-transport-handling.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, expect, test, beforeEach } from 'vitest'; -import { Protocol } from '../../src/shared/protocol.js'; -import { Transport } from '../../src/shared/transport.js'; -import { Request, Notification, Result, JSONRPCMessage } from '../../src/types.js'; -import * as z from 'zod/v4'; - -// 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 {} - })(); - - 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: Result) => void; - const handlerPromise = new Promise(resolve => { - resolveHandler = resolve; - }); - - const TestRequestSchema = z.object({ - method: z.literal('test/method'), - params: z - .object({ - from: z.string() - }) - .optional() - }); - - protocol.setRequestHandler(TestRequestSchema, async request => { - console.log(`Processing request from ${request.params?.from}`); - return handlerPromise; - }); - - // Client A connects and sends a request - await protocol.connect(transportA); - - const requestFromA = { - jsonrpc: '2.0' as const, - method: 'test/method', - params: { from: 'clientA' }, - id: 1 - }; - - // Simulate client A sending a request - transportA.onmessage?.(requestFromA); - - // While A's request is being processed, client B connects - // This overwrites the transport reference in the protocol - await protocol.connect(transportB); - - const requestFromB = { - jsonrpc: '2.0' as const, - method: 'test/method', - params: { from: 'clientB' }, - id: 2 - }; - - // Client B sends its own request - transportB.onmessage?.(requestFromB); - - // Now complete A's request - resolveHandler!({ data: 'responseForA' } as Result); - - // 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); - - // FIXED: Each transport now receives its own response - - // Transport A should receive response for request ID 1 - expect(transportA.sentMessages.length).toBe(1); - expect(transportA.sentMessages[0]).toMatchObject({ - jsonrpc: '2.0', - id: 1, - result: { data: 'responseForA' } - }); - - // Transport B should only receive its own response (when implemented) - expect(transportB.sentMessages.length).toBe(1); - expect(transportB.sentMessages[0]).toMatchObject({ - jsonrpc: '2.0', - id: 2, - result: { data: 'responseForA' } // Same handler result in this test - }); - }); - - test('demonstrates the timing issue with multiple rapid connections', async () => { - const delays: number[] = []; - const results: { transport: string; response: JSONRPCMessage[] }[] = []; - - const DelayedRequestSchema = z.object({ - method: z.literal('test/delayed'), - params: z - .object({ - delay: z.number(), - client: z.string() - }) - .optional() - }); - - // Set up handler with variable delay - protocol.setRequestHandler(DelayedRequestSchema, async (request, extra) => { - const delay = request.params?.delay || 0; - delays.push(delay); - - await new Promise(resolve => setTimeout(resolve, delay)); - - return { - processedBy: `handler-${extra.requestId}`, - delay: delay - } as Result; - }); - - // Rapid succession of connections and requests - await protocol.connect(transportA); - transportA.onmessage?.({ - jsonrpc: '2.0' as const, - method: 'test/delayed', - params: { delay: 50, client: 'A' }, - id: 1 - }); - - // Connect B while A is processing - setTimeout(async () => { - await protocol.connect(transportB); - transportB.onmessage?.({ - jsonrpc: '2.0' as const, - method: 'test/delayed', - params: { delay: 10, client: 'B' }, - 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); - - // FIXED: Each transport receives its own responses - expect(transportA.sentMessages.length).toBe(1); - expect(transportB.sentMessages.length).toBe(1); - }); -}); diff --git a/test/shared/protocol.test.ts b/test/shared/protocol.test.ts deleted file mode 100644 index 886dcbb21..000000000 --- a/test/shared/protocol.test.ts +++ /dev/null @@ -1,5558 +0,0 @@ -import { ZodType, z } from 'zod'; -import { - CallToolRequestSchema, - ClientCapabilities, - ErrorCode, - JSONRPCMessage, - McpError, - RELATED_TASK_META_KEY, - RequestId, - ServerCapabilities, - Task, - TaskCreationParams, - type Request, - type Notification, - type Result -} from '../../src/types.js'; -import { Protocol, mergeCapabilities } from '../../src/shared/protocol.js'; -import { Transport, TransportSendOptions } from '../../src/shared/transport.js'; -import { TaskStore, TaskMessageQueue, QueuedMessage, QueuedNotification, QueuedRequest } from '../../src/experimental/tasks/interfaces.js'; -import { MockInstance, vi } from 'vitest'; -import { JSONRPCResultResponse, JSONRPCRequest, JSONRPCErrorResponse } from '../../src/types.js'; -import { ErrorMessage, ResponseMessage, toArrayAsync } from '../../src/shared/responseMessage.js'; -import { InMemoryTaskMessageQueue } from '../../src/experimental/tasks/stores/in-memory.js'; - -// Type helper for accessing private/protected Protocol properties in tests -interface TestProtocol { - _taskMessageQueue?: TaskMessageQueue; - _requestResolvers: Map void>; - _responseHandlers: Map void>; - _taskProgressTokens: Map; - _clearTaskQueue: (taskId: string, sessionId?: string) => Promise; - requestTaskStore: (request: Request, authInfo: unknown) => TaskStore; - // Protected task methods (exposed for testing) - 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'); -} - -describe('protocol tests', () => { - let protocol: Protocol; - let transport: MockTransport; - let sendSpy: MockInstance; - - beforeEach(() => { - transport = new MockTransport(); - sendSpy = vi.spyOn(transport, 'send'); - protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - }); - - 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 protocol.request(request, mockSchema, { - timeout: 0 - }); - } catch (error) { - expect(error).toBeInstanceOf(McpError); - if (error instanceof McpError) { - expect(error.code).toBe(ErrorCode.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 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 protocol - .request(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 protocol - .request(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 protocol.request(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 protocol - .request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 = protocol.request(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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 protocol - .request(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 protocol - .request(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 = protocol.request(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 protocol - .request(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 TestProtocol)._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 TestProtocol)._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 protocol - .request(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 TestProtocol)._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 be handled by tool implementors, not protocol layer', () => { - // Task status management is now the responsibility of tool implementors - expect(true).toBe(true); - }); - - 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ taskStore: mockTaskStore }); - - await protocol.connect(transport); - - protocol.setRequestHandler(CallToolRequestSchema, async request => { - // Tool implementor can access task creation parameters from request.params.task - expect(request.params.task).toEqual({ - ttl: 60000, - pollInterval: 1000 - }); - return { result: '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('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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 TestProtocol).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 TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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(CallToolRequestSchema, async (_request, extra) => { - // Send a notification using the extra.sendNotification - await extra.sendNotification({ - 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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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; - const TestRequestSchema = z.object({ - method: z.literal('test/longRunning'), - params: z.optional(z.record(z.unknown())) - }); - protocol.setRequestHandler(TestRequestSchema, async (_request, extra) => { - // Simulate a long-running operation - await new Promise(resolve => setTimeout(resolve, 100)); - wasAborted = extra.signal.aborted; - return { _meta: {} } as Result; - }); - - // Simulate an incoming request - const requestId = 123; - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: requestId, - method: 'test/longRunning', - 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: ErrorCode.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: ErrorCode.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; - const TestMethodSchema = z.object({ - method: z.literal('test/method'), - params: z.optional(z.record(z.unknown())) - }); - protocol.setRequestHandler(TestMethodSchema, async () => { - await new Promise(resolve => setTimeout(resolve, 50)); - requestCompleted = true; - return { _meta: {} } as Result; - }); - - // Create a task - const task = await taskStore.createTask({ ttl: 60000 }, 'req-1', { - method: 'test/method', - params: {} - }); - - // Start a request - if (transport.onmessage) { - transport.onmessage({ - jsonrpc: '2.0', - id: 123, - method: 'test/method', - 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - }); - - it('should maintain progress token association after CreateTaskResult is returned', async () => { - const taskStore = createMockTaskStore(); - const protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 protocol - .request(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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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(CallToolRequestSchema, async (request, extra) => { - if (extra.taskStore) { - const task = await extra.taskStore.createTask({ ttl: 60000 }); - - // Simulate async work then complete the task - setTimeout(async () => { - await extra.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 protocol - .request(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 TestProtocol)._taskProgressTokens as Map; - expect(taskProgressTokens.has(taskId)).toBe(true); - expect(taskProgressTokens.get(taskId)).toBe(progressToken); - - // Simulate task completion by calling through the protocol's task store - // This will trigger the cleanup logic - const mockRequest = { jsonrpc: '2.0' as const, id: 999, method: 'test', params: {} }; - const requestTaskStore = (protocol as unknown as TestProtocol).requestTaskStore(mockRequest, undefined); - await requestTaskStore.storeTaskResult(taskId, 'completed', { content: [] }); - - // 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 protocol.request(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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 protocol.request(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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 protocol.request(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 protocol.request(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 protocol.request(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 protocol.request(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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = server.request( - { - 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 TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ taskStore, taskMessageQueue: new InMemoryTaskMessageQueue() }); - - await server.connect(transport); - - // Send a request without related task metadata - const requestPromise = server.request( - { - method: 'ping', - params: {} - }, - z.object({}) - ); - - // Verify queue exists (but we don't track size in the new API) - const queue = (server as unknown as TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = server.request( - { - method: 'ping', - params: {} - }, - z.object({}), - { - relatedTask: { taskId: task.taskId } - } - ); - - // Verify the resolver was stored - const resolvers = (server as unknown as TestProtocol)._requestResolvers; - expect(resolvers.size).toBe(1); - - // Get the request ID from the queue - const queue = (server as unknown as TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = server.request( - { - 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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 server.request( - { - 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 TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ 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 = server - .request( - { - 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( - server.request( - { - 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 TestProtocol)._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 = protocol.request( - { - 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 TestProtocol)._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 TestProtocol)._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 - const TestRequestSchema = z.object({ - method: z.literal('test/taskRequest'), - params: z - .object({ - _meta: z.optional(z.record(z.unknown())) - }) - .passthrough() - }); - - protocol.setRequestHandler(TestRequestSchema, async () => { - return { content: 'test result' } as Result; - }); - - // Simulate an incoming request with relatedTask metadata - const requestId = 456; - const taskId = 'task-response-test'; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'test/taskRequest', - 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 TestProtocol)._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({ content: 'test result' }); - } - }); - - 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 - const TestRequestSchema = z.object({ - method: z.literal('test/taskRequestError'), - params: z - .object({ - _meta: z.optional(z.record(z.unknown())) - }) - .passthrough() - }); - - protocol.setRequestHandler(TestRequestSchema, async () => { - throw new McpError(ErrorCode.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: 'test/taskRequestError', - 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 TestProtocol)._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(ErrorCode.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 TestProtocol)._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(ErrorCode.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 - const TestRequestSchema = z.object({ - method: z.literal('test/normalRequest'), - params: z.optional(z.record(z.unknown())) - }); - - protocol.setRequestHandler(TestRequestSchema, async () => { - return { content: 'normal result' } as Result; - }); - - // Simulate an incoming request WITHOUT relatedTask metadata - const requestId = 202; - transport.onmessage?.({ - jsonrpc: '2.0', - id: requestId, - method: 'test/normalRequest', - params: {} - }); - - // 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: 'normal result' } - }) - ); - }); - }); - - 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 TestProtocol)._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 = protocol.request( - { - 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 TestProtocol)._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 TestProtocol)._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 = protocol.request( - { - 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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 = protocol.request( - { - method: 'test/request', - params: { data: 'test' } - }, - mockSchema, - { relatedTask } - ); - - const queue = (protocol as unknown as TestProtocol)._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 TestProtocol)._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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ 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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 TestProtocol)._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 = protocol - .request({ 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 TestProtocol)._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; - expect(result).toBeInstanceOf(McpError); - 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 TestProtocol)._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 TestProtocol)._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 = protocol - .request({ 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 TestProtocol)._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocol)._clearTaskQueue(taskId); - - // Verify the request promise is rejected - const result = await requestPromise; - expect(result).toBeInstanceOf(McpError); - 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 = protocol - .request({ method: 'test/request1', params: { data: 'test1' } }, z.object({ result: z.string() }), { - relatedTask: { taskId } - }) - .catch(err => err); - - const request2Promise = protocol - .request({ method: 'test/request2', params: { data: 'test2' } }, z.object({ result: z.string() }), { - relatedTask: { taskId } - }) - .catch(err => err); - - const request3Promise = protocol - .request({ 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 TestProtocol)._taskMessageQueue; - expect(queue).toBeDefined(); - - // Directly call the cleanup method (simulating what happens when task reaches terminal status) - (protocol as unknown as TestProtocol)._clearTaskQueue(taskId); - - // Verify all request promises are rejected - const result1 = await request1Promise; - const result2 = await request2Promise; - const result3 = await request3Promise; - - expect(result1).toBeInstanceOf(McpError); - expect(result1.message).toContain('Task cancelled or completed'); - expect(result2).toBeInstanceOf(McpError); - expect(result2.message).toContain('Task cancelled or completed'); - expect(result3).toBeInstanceOf(McpError); - 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 = protocol - .request({ 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 TestProtocol)._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 TestProtocol)._clearTaskQueue(taskId); - - // Verify request promise is rejected - const result = await requestPromise; - expect(result).toBeInstanceOf(McpError); - 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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - // Start the request stream - const streamPromise = (async () => { - const messages = []; - const stream = (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - 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 TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocol).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.code).toBe(ErrorCode.RequestTimeout); - } finally { - vi.useRealTimers(); - } - }); - - test('should yield error as terminal message for cancellation', async () => { - const transport = new MockTransport(); - const protocol = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - const abortController = new AbortController(); - abortController.abort('User cancelled'); - - // Collect messages - const messages = await toArrayAsync( - (protocol as unknown as TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })({ taskStore: mockTaskStore }); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - 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 TestProtocol).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 = new (class extends Protocol { - protected assertCapabilityForMethod(): void {} - protected assertNotificationCapability(): void {} - protected assertRequestHandlerCapability(): void {} - protected assertTaskCapability(): void {} - protected assertTaskHandlerCapability(): void {} - })(); - await protocol.connect(transport); - - const messagesPromise = toArrayAsync( - (protocol as unknown as TestProtocol).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: ErrorCode.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 = new (class extends Protocol { - protected assertCapabilityForMethod(_method: string): void {} - protected assertNotificationCapability(_method: string): void {} - protected assertRequestHandlerCapability(_method: string): void {} - protected assertTaskCapability(_method: string): void {} - protected assertTaskHandlerCapability(_method: string): void {} - })({ - 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 TestProtocol; - - // 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._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 TestProtocol; - await testProtocol._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 TestProtocol; - testProtocol._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._clearTaskQueue(task.taskId); - - // Verify resolver was called with cancellation error - expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0][0]; - expect(calledError.code).toBe(ErrorCode.InternalError); - expect(calledError.message).toContain('Task cancelled or completed'); - - // Verify resolver was removed - expect(testProtocol._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 TestProtocol; - - // Enqueue multiple messages: request with resolver, request without, notification - const requestId1 = 42; - const resolverMock = vi.fn(); - testProtocol._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._clearTaskQueue(task.taskId); - - // Verify resolver was called for first request - expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - - // Verify the error has the correct properties - const calledError = resolverMock.mock.calls[0][0]; - expect(calledError.code).toBe(ErrorCode.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 TestProtocol; - 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 TestProtocol; - const responseMessage = msg.message as JSONRPCResultResponse; - const requestId = responseMessage.id as RequestId; - const resolver = testProtocol._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 TestProtocol; - - // This should not throw - await expect(testProtocol._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 TestProtocol; - testProtocol._requestResolvers.set(requestId, resolverMock); - - // Enqueue an error message - await taskMessageQueue.enqueue(task.taskId, { - type: 'error', - message: { - jsonrpc: '2.0', - id: requestId, - error: { - code: ErrorCode.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._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._requestResolvers.delete(reqId); - const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with McpError - expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - const calledError = resolverMock.mock.calls[0][0]; - expect(calledError.code).toBe(ErrorCode.InvalidRequest); - expect(calledError.message).toContain('Invalid request parameters'); - - // Verify resolver was removed from map - expect(testProtocol._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: ErrorCode.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 TestProtocol; - const errorMessage = queuedMessage.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._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 TestProtocol; - testProtocol._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: ErrorCode.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._requestResolvers.get(reqId); - - if (resolver) { - testProtocol._requestResolvers.delete(reqId); - const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - - // Verify resolver was called with McpError including data - expect(resolverMock).toHaveBeenCalledWith(expect.any(McpError)); - const calledError = resolverMock.mock.calls[0][0]; - expect(calledError.code).toBe(ErrorCode.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: ErrorCode.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 TestProtocol; - const errorMessage = msg.message as JSONRPCErrorResponse; - const requestId = errorMessage.id as RequestId; - const resolver = testProtocol._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 TestProtocol; - - // Set up resolvers for multiple requests - const resolver1 = vi.fn(); - const resolver2 = vi.fn(); - const resolver3 = vi.fn(); - - testProtocol._requestResolvers.set(1, resolver1); - testProtocol._requestResolvers.set(2, resolver2); - testProtocol._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: ErrorCode.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._requestResolvers.get(requestId); - if (resolver) { - testProtocol._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._requestResolvers.get(requestId); - if (resolver) { - testProtocol._requestResolvers.delete(requestId); - const error = new McpError(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(McpError)); - expect(resolver3).toHaveBeenCalledWith(expect.objectContaining({ id: 3 })); - - // Verify error has correct properties - const error = resolver2.mock.calls[0][0]; - expect(error.code).toBe(ErrorCode.InvalidRequest); - expect(error.message).toContain('Request failed'); - - // Verify all resolvers were removed - expect(testProtocol._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 TestProtocol; - - 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._requestResolvers.set(1, resolver1); - testProtocol._requestResolvers.set(2, resolver2); - testProtocol._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._requestResolvers.get(requestId); - if (resolver) { - testProtocol._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._requestResolvers.get(requestId); - if (resolver) { - testProtocol._requestResolvers.delete(requestId); - const error = new McpError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data); - resolver(error); - } - } - } - - // Verify FIFO order was maintained - expect(callOrder).toEqual([1, 2, 3]); - }); - }); -}); diff --git a/test/shared/stdio.test.ts b/test/shared/stdio.test.ts deleted file mode 100644 index e8cbb5245..000000000 --- a/test/shared/stdio.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { JSONRPCMessage } from '../../src/types.js'; -import { ReadBuffer } from '../../src/shared/stdio.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); -}); diff --git a/test/shared/toolNameValidation.test.ts b/test/shared/toolNameValidation.test.ts deleted file mode 100644 index bd3c5ea4f..000000000 --- a/test/shared/toolNameValidation.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { validateToolName, validateAndWarnToolName, issueToolNameWarning } from '../../src/shared/toolNameValidation.js'; -import { vi, MockInstance } from 'vitest'; - -// 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/test/shared/uriTemplate.test.ts b/test/shared/uriTemplate.test.ts deleted file mode 100644 index ec913c0db..000000000 --- a/test/shared/uriTemplate.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -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(100000); - 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(100000); - 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(10000).fill('{param}').join(''); - expect(() => new UriTemplate(expressions)).not.toThrow(); - }); - - it('should handle maximum variable name length', () => { - const longName = 'a'.repeat(10000); - const template = new UriTemplate(`{${longName}}`); - const vars: Record = {}; - vars[longName] = 'value'; - expect(() => template.expand(vars)).not.toThrow(); - }); - }); -}); diff --git a/vitest.workspace.js b/vitest.workspace.js new file mode 100644 index 000000000..b09f1f1fd --- /dev/null +++ b/vitest.workspace.js @@ -0,0 +1,3 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace(['packages/**/vitest.config.js']); From a089abc1e7afed3faf3a7d224ae6a4303e023918 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 11:51:17 +0200 Subject: [PATCH 08/22] save commit --- .github/workflows/main.yml | 81 +- common/tsconfig/tsconfig.json | 1 + common/vitest-config/package.json | 3 +- common/vitest-config/tsconfig.json | 8 + common/vitest-config/vitest.config.js | 20 +- package-lock.json | 4578 ----------------- package.json | 58 +- packages/client/package.json | 9 +- packages/client/src/client/auth-extensions.ts | 4 +- packages/client/src/client/auth.ts | 12 +- packages/client/src/client/client.ts | 16 +- packages/client/src/client/middleware.ts | 2 +- packages/client/src/client/sse.ts | 6 +- packages/client/src/client/stdio.ts | 6 +- packages/client/src/client/streamableHttp.ts | 7 +- packages/client/src/client/websocket.ts | 4 +- .../client/src/experimental/tasks/client.ts | 12 +- packages/client/src/index.ts | 2 +- packages/client/test/.gitkeep | 0 packages/client/tsconfig.build.json | 9 + packages/client/tsconfig.json | 16 +- packages/client/vitest.config.js | 16 +- .../client/vitest.setup.js | 3 +- packages/client/vitest.setup.ts | 1 - packages/core/package.json | 7 +- packages/core/src/shared/transport.ts | 1 + packages/core/tsconfig.build.json | 9 + packages/core/tsconfig.json | 15 +- packages/examples/{ => client}/README.md | 0 .../examples/{ => client}/eslint.config.mjs | 0 packages/examples/{ => client}/package.json | 9 +- .../src}/elicitationUrlExample.ts | 0 .../src}/multipleClientsParallel.ts | 0 .../src}/parallelToolCallsClient.ts | 0 .../src}/simpleClientCredentials.ts | 0 .../src}/simpleOAuthClient.ts | 0 .../src}/simpleOAuthClientProvider.ts | 0 .../src}/simpleStreamableHttp.ts | 0 .../src}/simpleTaskInteractiveClient.ts | 0 .../client => client/src}/ssePollingClient.ts | 0 .../streamableHttpWithSseFallbackClient.ts | 0 .../server/demoInMemoryOAuthProvider.test.ts | 0 packages/examples/client/tsconfig.build.json | 9 + packages/examples/client/tsconfig.json | 17 + packages/examples/client/vitest.config.js | 3 + packages/examples/server/README.md | 352 ++ packages/examples/server/eslint.config.mjs | 14 + packages/examples/server/package.json | 42 + .../src}/README-simpleTaskInteractive.md | 0 .../src}/demoInMemoryOAuthProvider.ts | 0 .../src}/elicitationFormExample.ts | 0 .../src}/elicitationUrlExample.ts | 4 +- .../src}/inMemoryEventStore.ts | 2 +- .../src}/jsonResponseStreamableHttp.ts | 0 .../src}/mcpServerOutputSchema.ts | 0 .../server => server/src}/simpleSseServer.ts | 0 .../src}/simpleStatelessStreamableHttp.ts | 0 .../src}/simpleStreamableHttp.ts | 4 +- .../src}/simpleTaskInteractive.ts | 0 .../sseAndStreamableHttpCompatibleServer.ts | 2 +- .../src}/ssePollingExample.ts | 2 +- .../standaloneSseWithGetStreamableHttp.ts | 0 .../src}/toolWithSampleServer.ts | 0 .../server/demoInMemoryOAuthProvider.test.ts | 263 + packages/examples/server/tsconfig.build.json | 9 + packages/examples/server/tsconfig.json | 17 + packages/examples/server/vitest.config.js | 3 + packages/examples/tsconfig.json | 18 - packages/examples/vitest.config.js | 18 - packages/integration/package.json | 6 - packages/integration/tsconfig.json | 9 +- packages/integration/vitest.config.js | 17 +- packages/server/package.json | 8 +- packages/server/src/experimental/index.js | 13 + .../server/src/experimental/tasks/index.js | 12 + .../src/experimental/tasks/interfaces.js | 6 + .../src/experimental/tasks/mcp-server.js | 43 + .../server/src/experimental/tasks/server.js | 90 + .../server/middleware/hostHeaderValidation.js | 75 + .../server/tsconfig.build.json | 2 +- packages/server/tsconfig.json | 5 +- packages/server/vitest.config.js | 17 +- pnpm-lock.yaml | 140 +- tsconfig.cjs.json | 10 - vitest.config.ts | 10 - 85 files changed, 1262 insertions(+), 4895 deletions(-) create mode 100644 common/vitest-config/tsconfig.json delete mode 100644 package-lock.json delete mode 100644 packages/client/test/.gitkeep create mode 100644 packages/client/tsconfig.build.json rename vitest.setup.ts => packages/client/vitest.setup.js (70%) delete mode 100644 packages/client/vitest.setup.ts create mode 100644 packages/core/tsconfig.build.json rename packages/examples/{ => client}/README.md (100%) rename packages/examples/{ => client}/eslint.config.mjs (100%) rename packages/examples/{ => client}/package.json (75%) rename packages/examples/{src/client => client/src}/elicitationUrlExample.ts (100%) rename packages/examples/{src/client => client/src}/multipleClientsParallel.ts (100%) rename packages/examples/{src/client => client/src}/parallelToolCallsClient.ts (100%) rename packages/examples/{src/client => client/src}/simpleClientCredentials.ts (100%) rename packages/examples/{src/client => client/src}/simpleOAuthClient.ts (100%) rename packages/examples/{src/client => client/src}/simpleOAuthClientProvider.ts (100%) rename packages/examples/{src/client => client/src}/simpleStreamableHttp.ts (100%) rename packages/examples/{src/client => client/src}/simpleTaskInteractiveClient.ts (100%) rename packages/examples/{src/client => client/src}/ssePollingClient.ts (100%) rename packages/examples/{src/client => client/src}/streamableHttpWithSseFallbackClient.ts (100%) rename packages/examples/{ => client}/test/server/demoInMemoryOAuthProvider.test.ts (100%) create mode 100644 packages/examples/client/tsconfig.build.json create mode 100644 packages/examples/client/tsconfig.json create mode 100644 packages/examples/client/vitest.config.js create mode 100644 packages/examples/server/README.md create mode 100644 packages/examples/server/eslint.config.mjs create mode 100644 packages/examples/server/package.json rename packages/examples/{src/server => server/src}/README-simpleTaskInteractive.md (100%) rename packages/examples/{src/server => server/src}/demoInMemoryOAuthProvider.ts (100%) rename packages/examples/{src/server => server/src}/elicitationFormExample.ts (100%) rename packages/examples/{src/server => server/src}/elicitationUrlExample.ts (99%) rename packages/examples/{src/shared => server/src}/inMemoryEventStore.ts (97%) rename packages/examples/{src/server => server/src}/jsonResponseStreamableHttp.ts (100%) rename packages/examples/{src/server => server/src}/mcpServerOutputSchema.ts (100%) rename packages/examples/{src/server => server/src}/simpleSseServer.ts (100%) rename packages/examples/{src/server => server/src}/simpleStatelessStreamableHttp.ts (100%) rename packages/examples/{src/server => server/src}/simpleStreamableHttp.ts (99%) rename packages/examples/{src/server => server/src}/simpleTaskInteractive.ts (100%) rename packages/examples/{src/server => server/src}/sseAndStreamableHttpCompatibleServer.ts (99%) rename packages/examples/{src/server => server/src}/ssePollingExample.ts (98%) rename packages/examples/{src/server => server/src}/standaloneSseWithGetStreamableHttp.ts (100%) rename packages/examples/{src/server => server/src}/toolWithSampleServer.ts (100%) create mode 100644 packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts create mode 100644 packages/examples/server/tsconfig.build.json create mode 100644 packages/examples/server/tsconfig.json create mode 100644 packages/examples/server/vitest.config.js delete mode 100644 packages/examples/tsconfig.json delete mode 100644 packages/examples/vitest.config.js create mode 100644 packages/server/src/experimental/index.js create mode 100644 packages/server/src/experimental/tasks/index.js create mode 100644 packages/server/src/experimental/tasks/interfaces.js create mode 100644 packages/server/src/experimental/tasks/mcp-server.js create mode 100644 packages/server/src/experimental/tasks/server.js create mode 100644 packages/server/src/server/middleware/hostHeaderValidation.js rename tsconfig.prod.json => packages/server/tsconfig.build.json (85%) delete mode 100644 tsconfig.cjs.json delete mode 100644 vitest.config.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 60144add1..6e64b0c9a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,32 +16,93 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + run_install: false + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - run: npm ci - - run: npm run check - - run: npm run build + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm-store- + + - uses: actions/cache@v4 + name: Retrieve Cache + with: + path: | + node_modules/.cache + node_modules/.vitest + key: ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} + ${{ runner.os }}-build- + ${{ runner.os }}- + + - 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: [18, 24] + node-version: [22, 24] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} cache: npm - - run: npm ci - - run: npm test + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + run_install: false + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm-store- + + - uses: actions/cache@v4 + name: Retrieve Cache + with: + path: | + node_modules/.cache + node_modules/.vitest + key: ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} + ${{ runner.os }}-build- + ${{ runner.os }}- + - run: pnpm test:all publish: runs-on: ubuntu-latest diff --git a/common/tsconfig/tsconfig.json b/common/tsconfig/tsconfig.json index 7a40758bc..bb020a0f7 100644 --- a/common/tsconfig/tsconfig.json +++ b/common/tsconfig/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "esnext", + "lib": ["esnext"], "module": "NodeNext", "moduleResolution": "NodeNext", "allowSyntheticDefaultImports": true, diff --git a/common/vitest-config/package.json b/common/vitest-config/package.json index b244e0773..8dc9d0368 100644 --- a/common/vitest-config/package.json +++ b/common/vitest-config/package.json @@ -22,6 +22,7 @@ }, "version": "2.0.0", "devDependencies": { - "@modelcontextprotocol/tsconfig": "workspace:^" + "@modelcontextprotocol/tsconfig": "workspace:^", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/common/vitest-config/tsconfig.json b/common/vitest-config/tsconfig.json new file mode 100644 index 000000000..6e58368d2 --- /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 index b7b0410e9..9d1a094e7 100644 --- a/common/vitest-config/vitest.config.js +++ b/common/vitest-config/vitest.config.js @@ -1,9 +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'] - } + 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/package-lock.json b/package-lock.json deleted file mode 100644 index d32963a73..000000000 --- a/package-lock.json +++ /dev/null @@ -1,4578 +0,0 @@ -{ - "name": "@modelcontextprotocol/sdk", - "version": "1.24.3", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@modelcontextprotocol/sdk", - "version": "1.24.3", - "license": "MIT", - "dependencies": { - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" - }, - "devDependencies": { - "@cfworker/json-schema": "^4.1.1", - "@eslint/js": "^9.39.1", - "@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.0", - "@types/express-serve-static-core": "^5.1.0", - "@types/node": "^22.12.0", - "@types/supertest": "^6.0.2", - "@types/ws": "^8.5.12", - "@typescript/native-preview": "^7.0.0-dev.20251103.1", - "eslint": "^9.8.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-n": "^17.23.1", - "prettier": "3.6.2", - "supertest": "^7.0.0", - "tsx": "^4.16.5", - "typescript": "^5.5.4", - "typescript-eslint": "^8.48.1", - "vitest": "^4.0.8", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@cfworker/json-schema": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", - "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/content-type": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.8.tgz", - "integrity": "sha512-1tBhmVUeso3+ahfyaKluXe38p+94lovUZdoVfQ3OnJo9uJC42JT7CBoN3k9HYhAae+GwiBYmHu+N9FZhOG+2Pg==", - "dev": true - }, - "node_modules/@types/cookiejar": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", - "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cross-spawn": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", - "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/eventsource": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", - "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", - "dev": true - }, - "node_modules/@types/express": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", - "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/methods": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", - "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true - }, - "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.9.18", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", - "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", - "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, - "node_modules/@types/superagent": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", - "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/supertest": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", - "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/methods": "^1.1.4", - "@types/superagent": "^8.1.0" - } - }, - "node_modules/@types/ws": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", - "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", - "integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/type-utils": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.48.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz", - "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz", - "integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.1", - "@typescript-eslint/types": "^8.48.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz", - "integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz", - "integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz", - "integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz", - "integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz", - "integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.48.1", - "@typescript-eslint/tsconfig-utils": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/visitor-keys": "8.48.1", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz", - "integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.1", - "@typescript-eslint/types": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz", - "integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.1", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-Pcyltv+XIbaCoRaD3btY3qu+B1VzvEgNGlq1lM0O11QTPRLHyoEfvtLqyPKuSDgD90gDbtCPGUppVkpQouLBVQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsgo": "bin/tsgo.js" - }, - "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251103.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251103.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20251103.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251103.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20251103.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251103.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20251103.1" - } - }, - "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-yqUxUts3zpxy0x+Rk/9VC+ZiwzXTiuNpgLbhLAR1inFxuk0kTM8xoQERaIk+DUn6guEmRiCzOw23aJ9u6E+GfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-jboMuar6TgvnnOZk8t/X2gZp4TUtsP9xtUnLEMEHRPWK3LFBJpjDFRUH70vOpW9hWIKYajlkF8JutclCPX5sBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-QY+0W9TPxHub8vSFjemo3txSpCNGw3LqnrLKKlGUIuLW+Ohproo+o7Fq21dksPQ4g0NDWY19qlm/36QhsXKRNQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-PZewTo76n2chP8o0Fwq2543jVVSY7aiZMBsapB82+w/XecFuCQtFRYNN02x6pjHeVjgv5fcWS3+LzHa1zv10qw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-wdFUmmz5XFUvWQ54l3f8ODah86b6Z4FnG9gndjOdYRY2FGDCOdmeoBqLHDiGUIzTHr5FMMyz2EfScN+qtUh4Dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-A00+b8mbwJ4RFwXZN4vNcIBGZcdBCFm23lBhw8uaUgLY1Ot81FZvJE3YZcbRrZwEiyrwd3hAMdnDBWUwMA9YqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20251103.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20251103.1.tgz", - "integrity": "sha512-25Pqk65M3fjQdsnwBLym5ALSdQlQAqHKrzZOkIs1uFKxIfZ5s9658Kjfj2fiMX5m3imk9IqzpP+fvKbgP1plIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vitest/expect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.8.tgz", - "integrity": "sha512-Rv0eabdP/xjAHQGr8cjBm+NnLHNoL268lMDK85w2aAGLFoVKLd8QGnVon5lLtkXQCoYaNL0wg04EGnyKkkKhPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", - "chai": "^6.2.0", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.8.tgz", - "integrity": "sha512-qRrjdRkINi9DaZHAimV+8ia9Gq6LeGz2CgIEmMLz3sBDYV53EsnLZbJMR1q84z1HZCMsf7s0orDgZn7ScXsZKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.8.tgz", - "integrity": "sha512-mdY8Sf1gsM8hKJUQfiPT3pn1n8RF4QBcJYFslgWh41JTfrK1cbqY8whpGCFzBl45LN028g0njLCYm0d7XxSaQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.0.8", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.8.tgz", - "integrity": "sha512-Nar9OTU03KGiubrIOFhcfHg8FYaRaNT+bh5VUlNz8stFhCZPNrJvmZkhsr1jtaYvuefYFwK2Hwrq026u4uPWCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.8", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.8.tgz", - "integrity": "sha512-nvGVqUunyCgZH7kmo+Ord4WgZ7lN0sOULYXUOYuHr55dvg9YvMz3izfB189Pgp28w0vWFbEEfNc/c3VTrqrXeA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.8.tgz", - "integrity": "sha512-pdk2phO5NDvEFfUTxcTP8RFYjVj/kfLSPIN5ebP2Mu9kcIMeAQTbknqcFEyBcC4z2pJlJI9aS5UQjcYfhmKAow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.8", - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", - "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", - "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", - "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-es-x": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", - "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/ota-meshi", - "https://opencollective.com/eslint" - ], - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.1.2", - "@eslint-community/regexpp": "^4.11.0", - "eslint-compat-utils": "^0.5.1" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": ">=8" - } - }, - "node_modules/eslint-plugin-n": { - "version": "17.23.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.23.1.tgz", - "integrity": "sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.5.0", - "enhanced-resolve": "^5.17.1", - "eslint-plugin-es-x": "^7.8.0", - "get-tsconfig": "^4.8.1", - "globals": "^15.11.0", - "globrex": "^0.1.2", - "ignore": "^5.3.2", - "semver": "^7.6.3", - "ts-declaration-location": "^1.0.6" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": ">=8.23.0" - } - }, - "node_modules/eslint-plugin-n/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", - "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", - "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", - "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/form-data/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", - "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "function-bind": "^1.1.2", - "get-proto": "^1.0.0", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", - "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", - "dev": true, - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/jose": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.1.tgz", - "integrity": "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superagent": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", - "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^3.5.1", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supertest": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", - "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^9.0.1" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-declaration-location": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/ts-declaration-location/-/ts-declaration-location-1.0.7.tgz", - "integrity": "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==", - "dev": true, - "funding": [ - { - "type": "ko-fi", - "url": "https://ko-fi.com/rebeccastevens" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/ts-declaration-location" - } - ], - "license": "BSD-3-Clause", - "dependencies": { - "picomatch": "^4.0.2" - }, - "peerDependencies": { - "typescript": ">=4.0.0" - } - }, - "node_modules/ts-declaration-location/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tsx": { - "version": "4.19.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", - "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.48.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz", - "integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.48.1", - "@typescript-eslint/parser": "8.48.1", - "@typescript-eslint/typescript-estree": "8.48.1", - "@typescript-eslint/utils": "8.48.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vitest": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.8.tgz", - "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.0.8", - "@vitest/mocker": "4.0.8", - "@vitest/pretty-format": "4.0.8", - "@vitest/runner": "4.0.8", - "@vitest/snapshot": "4.0.8", - "@vitest/spy": "4.0.8", - "@vitest/utils": "4.0.8", - "debug": "^4.4.3", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.8", - "@vitest/browser-preview": "4.0.8", - "@vitest/browser-webdriverio": "4.0.8", - "@vitest/ui": "4.0.8", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "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 - } - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.8.tgz", - "integrity": "sha512-9FRM3MZCedXH3+pIh+ME5Up2NBBHDq0wqwhOKkN4VnvCiKbVxddqH9mSGPZeawjd12pCOGnl+lo/ZGHt0/dQSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.0.8", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", - "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "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 - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "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 - } - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", - "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } - } - } -} diff --git a/package.json b/package.json index 37c6118ca..058520f57 100644 --- a/package.json +++ b/package.json @@ -12,67 +12,19 @@ "url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git" }, "engines": { - "node": ">=18" + "node": ">=20" }, "keywords": [ "modelcontextprotocol", "mcp" ], - "exports": { - ".": { - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" - }, - "./client": { - "import": "./dist/esm/client/index.js", - "require": "./dist/cjs/client/index.js" - }, - "./server": { - "import": "./dist/esm/server/index.js", - "require": "./dist/cjs/server/index.js" - }, - "./validation": { - "import": "./dist/esm/validation/index.js", - "require": "./dist/cjs/validation/index.js" - }, - "./validation/ajv": { - "import": "./dist/esm/validation/ajv-provider.js", - "require": "./dist/cjs/validation/ajv-provider.js" - }, - "./validation/cfworker": { - "import": "./dist/esm/validation/cfworker-provider.js", - "require": "./dist/cjs/validation/cfworker-provider.js" - }, - "./experimental": { - "import": "./dist/esm/experimental/index.js", - "require": "./dist/cjs/experimental/index.js" - }, - "./experimental/tasks": { - "import": "./dist/esm/experimental/tasks/index.js", - "require": "./dist/cjs/experimental/tasks/index.js" - }, - "./*": { - "import": "./dist/esm/*", - "require": "./dist/cjs/*" - } - }, - "typesVersions": { - "*": { - "*": [ - "./dist/esm/*" - ] - } - }, - "files": [ - "dist" - ], "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", - "typecheck": "tsgo --noEmit", + "typecheck:all": "pnpm -r typecheck", "build:all": "pnpm -r build", "prepack:all": "pnpm -r prepack", "lint:all": "pnpm -r lint", - "lint:fix": "eslint src/ --fix && prettier --write .", + "lint:fix:all": "pnpm -r lint:fix", "check:all": "pnpm -r check", "test:all": "pnpm -r test" }, @@ -114,7 +66,7 @@ "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.1.0", - "@types/node": "^22.12.0", + "@types/node": "^24.10.1", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", @@ -124,7 +76,7 @@ "prettier": "3.6.2", "supertest": "^7.0.0", "tsx": "^4.16.5", - "typescript": "^5.5.4", + "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", "vitest": "^4.0.8", "ws": "^8.18.0" diff --git a/packages/client/package.json b/packages/client/package.json index 5f169ed23..4fdca6b93 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -34,13 +34,11 @@ "dist" ], "scripts": { - "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc -p tsconfig.build.json --noEmit", "build": "npm run build:esm", - "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.build.json", "build:esm:w": "npm run build:esm -- -w", - "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", - "prepack": "npm run build:esm && npm run build:cjs", + "prepack": "npm run build:esm", "lint": "eslint src/ && prettier --check .", "lint:fix": "eslint src/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", @@ -92,7 +90,6 @@ "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.1.0", - "@types/node": "^22.12.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts index 69dcbaf9e..e63630834 100644 --- a/packages/client/src/client/auth-extensions.ts +++ b/packages/client/src/client/auth-extensions.ts @@ -5,8 +5,8 @@ * for common machine-to-machine authentication scenarios. */ -import type { JWK } from 'jose'; -import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../../../core/src/index.js'; +import type { CryptoKey, JWK } from 'jose'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 61a97d44b..b174c780a 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,5 +1,5 @@ import pkceChallenge from 'pkce-challenge'; -import { LATEST_PROTOCOL_VERSION } from '../../../core/src/index.js'; +import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk-core'; import { OAuthClientMetadata, OAuthClientInformation, @@ -11,14 +11,14 @@ import { OAuthErrorResponseSchema, AuthorizationServerMetadata, OpenIdProviderDiscoveryMetadataSchema -} from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema -} from '../../../core/src/index.js'; -import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; +import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk-core'; import { InvalidClientError, InvalidClientMetadataError, @@ -27,8 +27,8 @@ import { OAuthError, ServerError, UnauthorizedClientError -} from '../../../core/src/index.js'; -import { FetchLike } from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; +import { FetchLike } from '@modelcontextprotocol/sdk-core'; /** * Function type for adding client authentication to token requests. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 100e279f8..1be877849 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,5 +1,5 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../../../core/src/index.js'; -import type { Transport } from '../../../core/src/index.js'; +import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/sdk-core'; +import type { Transport } from '@modelcontextprotocol/sdk-core'; import { type CallToolRequest, @@ -49,9 +49,9 @@ import { type Request, type Notification, type Result -} from '../../../core/src/index.js'; -import { AjvJsonSchemaValidator } from '../../../core/src/index.js'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; import { AnyObjectSchema, SchemaOutput, @@ -60,10 +60,10 @@ import { safeParse, type ZodV3Internal, type ZodV4Internal -} from '../../../core/src/index.js'; -import type { RequestHandlerExtra } from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk-core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../../../core/src/index.js'; +import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/sdk-core'; /** * Elicitation default application helper. Applies defaults to the data based on the schema. diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index f4ae00806..46061ba16 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,5 +1,5 @@ import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { FetchLike } from '../../../core/src/index.js'; +import { FetchLike } from '@modelcontextprotocol/sdk-core'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 5095c8b3b..e886430a6 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,6 +1,6 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../../../core/src/index.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../../../core/src/index.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; export class SseError extends Error { @@ -114,7 +114,7 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit & Record = {}; + const headers: Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index 2a9686831..f070fe129 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -2,9 +2,9 @@ import { ChildProcess, IOType } from 'node:child_process'; import spawn from 'cross-spawn'; import process from 'node:process'; import { Stream, PassThrough } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../../../core/src/index.js'; -import { Transport } from '../../../core/src/index.js'; -import { JSONRPCMessage } from '../../../core/src/index.js'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; +import { Transport } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; export type StdioServerParameters = { /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index ceeecd236..d91e879ac 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,13 +1,14 @@ -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../../../core/src/index.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/sdk-core'; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessage, JSONRPCMessageSchema -} from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; +import { ReadableWritablePair } from 'node:stream/web'; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -186,7 +187,7 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: HeadersInit & Record = {}; + const headers: Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index b31382a3e..eae23c5da 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,5 +1,5 @@ -import { Transport } from '../../../core/src/index.js'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '../../../core/src/index.js'; +import { Transport } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; const SUBPROTOCOL = 'mcp'; diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 5a7f304a3..8536f0cf8 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -6,13 +6,13 @@ */ import type { Client } from '../../client/client.js'; -import type { RequestOptions } from '../../../../core/src/index.js'; -import type { ResponseMessage } from '../../../../core/src/index.js'; -import type { AnyObjectSchema, SchemaOutput } from '../../../../core/src/index.js'; -import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '../../../../core/src/index.js'; -import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '../../../../core/src/index.js'; +import type { RequestOptions } from '@modelcontextprotocol/sdk-core'; +import type { ResponseMessage } from '@modelcontextprotocol/sdk-core'; +import type { AnyObjectSchema, SchemaOutput } from '@modelcontextprotocol/sdk-core'; +import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '@modelcontextprotocol/sdk-core'; +import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk-core'; -import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '../../../../core/src/index.js'; +import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/sdk-core'; /** * Internal interface for accessing Client's private methods. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 286e2f50a..ea7fdd1f5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -11,4 +11,4 @@ export * from './client/websocket.js'; export * from './experimental/index.js'; // re-export shared types -export * from '../../core/src/index.js'; +export * from '@modelcontextprotocol/sdk-core'; diff --git a/packages/client/test/.gitkeep b/packages/client/test/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 000000000..eabb8d8ff --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 59650b684..862c7a523 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -1,19 +1,15 @@ { "extends": "@modelcontextprotocol/tsconfig", - "include": ["./", "../integration/test/index.test.ts"], + "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { - "rootDir": ".", + "baseUrl": ".", "paths": { "*": ["./*"], - "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], - "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], - "@modelcontextprotocol/sdk-client": ["./src/index.ts"], - "@modelcontextprotocol/sdk-client/*": ["./src/*"], - "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], - "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], - "@modelcontextprotocol/vitest-config": ["../common/vitest-config/tsconfig.json"], - "@modelcontextprotocol/eslint-config": ["../common/eslint-config/tsconfig.json"] + "@modelcontextprotocol/sdk-core": ["node_modules/@modelcontextprotocol/sdk-core/src/index.ts"], + "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"] } } } diff --git a/packages/client/vitest.config.js b/packages/client/vitest.config.js index 9dd7945cc..2012fa59e 100644 --- a/packages/client/vitest.config.js +++ b/packages/client/vitest.config.js @@ -1,22 +1,8 @@ import baseConfig from '@modelcontextprotocol/vitest-config'; import { mergeConfig } from 'vitest/config'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); export default mergeConfig(baseConfig, { test: { - setupFiles: ['./vitest.setup.ts'] - }, - resolve: { - alias: { - // Use workspace source packages instead of built dist/ for tests - '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), - '@modelcontextprotocol/sdk-core/types': path.resolve(__dirname, '../core/src/exports/types/index.ts'), - '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), - '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') - } + setupFiles: ['./vitest.setup.js'] } }); diff --git a/vitest.setup.ts b/packages/client/vitest.setup.js similarity index 70% rename from vitest.setup.ts rename to packages/client/vitest.setup.js index 820dcbd89..d6e9c6678 100644 --- a/vitest.setup.ts +++ b/packages/client/vitest.setup.js @@ -3,6 +3,5 @@ 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') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).crypto = webcrypto as unknown as Crypto; + globalThis.crypto = webcrypto; } diff --git a/packages/client/vitest.setup.ts b/packages/client/vitest.setup.ts deleted file mode 100644 index 292abcb9f..000000000 --- a/packages/client/vitest.setup.ts +++ /dev/null @@ -1 +0,0 @@ -import '../../vitest.setup'; diff --git a/packages/core/package.json b/packages/core/package.json index 553b93541..9c153acdc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,12 +42,8 @@ ], "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", - "typecheck": "tsgo --noEmit", - "build": "npm run build:esm", - "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", - "build:esm:w": "npm run build:esm -- -w", + "typecheck": "tsc -p tsconfig.build.json --noEmit", "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", - "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint src/ && prettier --check .", "lint:fix": "eslint src/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", @@ -98,7 +94,6 @@ "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.1.0", - "@types/node": "^22.12.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index 359d59bfc..bb65ac83b 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,5 @@ import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/types.js'; +import type { HeadersInit } from 'undici-types'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 000000000..eabb8d8ff --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index d6981f997..c23f7547e 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,18 +1,15 @@ { "extends": "@modelcontextprotocol/tsconfig", - "include": ["./", "../integration/test/helpers"], + "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { + "baseUrl": ".", "paths": { "*": ["./*"], - "@modelcontextprotocol/sdk-core": ["./src/index.ts"], - "@modelcontextprotocol/sdk-core/*": ["./src/*"], - "@modelcontextprotocol/sdk-client": ["../client/src/index.ts"], - "@modelcontextprotocol/sdk-client/*": ["../client/src/*"], - "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], - "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], - "@modelcontextprotocol/eslint-config": ["./node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/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/packages/examples/README.md b/packages/examples/client/README.md similarity index 100% rename from packages/examples/README.md rename to packages/examples/client/README.md diff --git a/packages/examples/eslint.config.mjs b/packages/examples/client/eslint.config.mjs similarity index 100% rename from packages/examples/eslint.config.mjs rename to packages/examples/client/eslint.config.mjs diff --git a/packages/examples/package.json b/packages/examples/client/package.json similarity index 75% rename from packages/examples/package.json rename to packages/examples/client/package.json index e6bcffe48..d4357c6ab 100644 --- a/packages/examples/package.json +++ b/packages/examples/client/package.json @@ -1,5 +1,5 @@ { - "name": "@modelcontextprotocol/sdk-examples", + "name": "@modelcontextprotocol/sdk-examples-client", "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", @@ -20,11 +20,7 @@ "mcp" ], "scripts": { - "typecheck": "tsgo --noEmit", - "build": "npm run build:esm", - "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", - "build:esm:w": "npm run build:esm -- -w", - "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", + "typecheck": "tsc -p tsconfig.build.json --noEmit", "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint test/ && prettier --check .", "lint:fix": "eslint test/ --fix && prettier --write .", @@ -36,7 +32,6 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/sdk-server": "workspace:^", "@modelcontextprotocol/sdk-client": "workspace:^" }, "devDependencies": { diff --git a/packages/examples/src/client/elicitationUrlExample.ts b/packages/examples/client/src/elicitationUrlExample.ts similarity index 100% rename from packages/examples/src/client/elicitationUrlExample.ts rename to packages/examples/client/src/elicitationUrlExample.ts diff --git a/packages/examples/src/client/multipleClientsParallel.ts b/packages/examples/client/src/multipleClientsParallel.ts similarity index 100% rename from packages/examples/src/client/multipleClientsParallel.ts rename to packages/examples/client/src/multipleClientsParallel.ts diff --git a/packages/examples/src/client/parallelToolCallsClient.ts b/packages/examples/client/src/parallelToolCallsClient.ts similarity index 100% rename from packages/examples/src/client/parallelToolCallsClient.ts rename to packages/examples/client/src/parallelToolCallsClient.ts diff --git a/packages/examples/src/client/simpleClientCredentials.ts b/packages/examples/client/src/simpleClientCredentials.ts similarity index 100% rename from packages/examples/src/client/simpleClientCredentials.ts rename to packages/examples/client/src/simpleClientCredentials.ts diff --git a/packages/examples/src/client/simpleOAuthClient.ts b/packages/examples/client/src/simpleOAuthClient.ts similarity index 100% rename from packages/examples/src/client/simpleOAuthClient.ts rename to packages/examples/client/src/simpleOAuthClient.ts diff --git a/packages/examples/src/client/simpleOAuthClientProvider.ts b/packages/examples/client/src/simpleOAuthClientProvider.ts similarity index 100% rename from packages/examples/src/client/simpleOAuthClientProvider.ts rename to packages/examples/client/src/simpleOAuthClientProvider.ts diff --git a/packages/examples/src/client/simpleStreamableHttp.ts b/packages/examples/client/src/simpleStreamableHttp.ts similarity index 100% rename from packages/examples/src/client/simpleStreamableHttp.ts rename to packages/examples/client/src/simpleStreamableHttp.ts diff --git a/packages/examples/src/client/simpleTaskInteractiveClient.ts b/packages/examples/client/src/simpleTaskInteractiveClient.ts similarity index 100% rename from packages/examples/src/client/simpleTaskInteractiveClient.ts rename to packages/examples/client/src/simpleTaskInteractiveClient.ts diff --git a/packages/examples/src/client/ssePollingClient.ts b/packages/examples/client/src/ssePollingClient.ts similarity index 100% rename from packages/examples/src/client/ssePollingClient.ts rename to packages/examples/client/src/ssePollingClient.ts diff --git a/packages/examples/src/client/streamableHttpWithSseFallbackClient.ts b/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts similarity index 100% rename from packages/examples/src/client/streamableHttpWithSseFallbackClient.ts rename to packages/examples/client/src/streamableHttpWithSseFallbackClient.ts diff --git a/packages/examples/test/server/demoInMemoryOAuthProvider.test.ts b/packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts similarity index 100% rename from packages/examples/test/server/demoInMemoryOAuthProvider.test.ts rename to packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts diff --git a/packages/examples/client/tsconfig.build.json b/packages/examples/client/tsconfig.build.json new file mode 100644 index 000000000..eabb8d8ff --- /dev/null +++ b/packages/examples/client/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/examples/client/tsconfig.json b/packages/examples/client/tsconfig.json new file mode 100644 index 000000000..2c3172ef1 --- /dev/null +++ b/packages/examples/client/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["./*"], + "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/sdk-core": [ + "node_modules/@modelcontextprotocol/sdk-client/node_modules/@modelcontextprotocol/sdk-core/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/packages/examples/client/vitest.config.js b/packages/examples/client/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/examples/client/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/examples/server/README.md b/packages/examples/server/README.md new file mode 100644 index 000000000..c8f7c4352 --- /dev/null +++ b/packages/examples/server/README.md @@ -0,0 +1,352 @@ +# MCP TypeScript SDK Examples + +This directory contains example implementations of MCP clients and servers using the TypeScript SDK. For a high-level index of scenarios and where they live, see the **Examples** table in the root `README.md`. + +## Table of Contents + +- [Client Implementations](#client-implementations) + - [Streamable HTTP Client](#streamable-http-client) + - [Backwards Compatible Client](#backwards-compatible-client) + - [URL Elicitation Example Client](#url-elicitation-example-client) +- [Server Implementations](#server-implementations) + - [Single Node Deployment](#single-node-deployment) + - [Streamable HTTP Transport](#streamable-http-transport) + - [Deprecated SSE Transport](#deprecated-sse-transport) + - [Backwards Compatible Server](#streamable-http-backwards-compatible-server-with-sse) + - [Form Elicitation Example](#form-elicitation-example) + - [URL Elicitation Example](#url-elicitation-example) + - [Multi-Node Deployment](#multi-node-deployment) +- [Backwards Compatibility](#testing-streamable-http-backwards-compatibility-with-sse) + +## Client Implementations + +### Streamable HTTP Client + +A full-featured interactive client that connects to a Streamable HTTP server, demonstrating how to: + +- Establish and manage a connection to an MCP server +- List and call tools with arguments +- Handle notifications through the SSE stream +- List and get prompts with arguments +- List available resources +- Handle session termination and reconnection +- Support for resumability with Last-Event-ID tracking + +```bash +npx tsx src/examples/client/simpleStreamableHttp.ts +``` + +Example client with OAuth: + +```bash +npx tsx src/examples/client/simpleOAuthClient.ts +``` + +Client credentials (machine-to-machine) example: + +```bash +npx tsx src/examples/client/simpleClientCredentials.ts +``` + +### Backwards Compatible Client + +A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: + +- The client first POSTs an initialize request to the server URL: + - If successful, it uses the Streamable HTTP transport + - If it fails with a 4xx status, it attempts a GET request to establish an SSE stream + +```bash +npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts +``` + +### URL Elicitation Example Client + +A client that demonstrates how to use URL elicitation to securely collect _sensitive_ user input or perform secure third-party flows. + +```bash +# First, run the server: +npx tsx src/examples/server/elicitationUrlExample.ts + +# Then, run the client: +npx tsx src/examples/client/elicitationUrlExample.ts + +``` + +## Server Implementations + +### Single Node Deployment + +These examples demonstrate how to set up an MCP server on a single node with different transport options. + +#### Streamable HTTP Transport + +##### Simple Streamable HTTP Server + +A server that implements the Streamable HTTP transport (protocol version 2025-11-25). + +- Basic server setup with Express and the Streamable HTTP transport +- Session management with an in-memory event store for resumability +- Tool implementation with the `greet` and `multi-greet` tools +- Prompt implementation with the `greeting-template` prompt +- Static resource exposure +- Support for notifications via SSE stream established by GET requests +- Session termination via DELETE requests + +```bash +npx tsx src/examples/server/simpleStreamableHttp.ts + +# To add a demo of authentication to this example, use: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict +``` + +##### JSON Response Mode Server + +A server that uses Streamable HTTP transport with JSON response mode enabled (no SSE). + +- Streamable HTTP with JSON response mode, which returns responses directly in the response body +- Limited support for notifications (since SSE is disabled) +- Proper response handling according to the MCP specification for servers that don't support SSE +- Returning appropriate HTTP status codes for unsupported methods + +```bash +npx tsx src/examples/server/jsonResponseStreamableHttp.ts +``` + +##### Streamable HTTP with server notifications + +A server that demonstrates server notifications using Streamable HTTP. + +- Resource list change notifications with dynamically added resources +- Automatic resource creation on a timed interval + +```bash +npx tsx src/examples/server/standaloneSseWithGetStreamableHttp.ts +``` + +##### Form Elicitation Example + +A server that demonstrates using form elicitation to collect _non-sensitive_ user input. + +```bash +npx tsx src/examples/server/elicitationFormExample.ts +``` + +##### URL Elicitation Example + +A comprehensive example demonstrating URL mode elicitation in a server protected by MCP authorization. This example shows: + +- SSE-driven URL elicitation of an API Key on session initialization: obtain sensitive user input at session init +- Tools that require direct user interaction via URL elicitation (for payment confirmation and for third-party OAuth tokens) +- Completion notifications for URL elicitation + +To run this example: + +```bash +# Start the server +npx tsx src/examples/server/elicitationUrlExample.ts + +# In a separate terminal, start the client +npx tsx src/examples/client/elicitationUrlExample.ts +``` + +#### Deprecated SSE Transport + +A server that implements the deprecated HTTP+SSE transport (protocol version 2024-11-05). This example is only used for testing backwards compatibility for clients. + +- Two separate endpoints: `/mcp` for the SSE stream (GET) and `/messages` for client messages (POST) +- Tool implementation with a `start-notification-stream` tool that demonstrates sending periodic notifications + +```bash +npx tsx src/examples/server/simpleSseServer.ts +``` + +#### Streamable Http Backwards Compatible Server with SSE + +A server that supports both Streamable HTTP and SSE transports, adhering to the [MCP specification for backwards compatibility](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility). + +- Single MCP server instance with multiple transport options +- Support for Streamable HTTP requests at `/mcp` endpoint (GET/POST/DELETE) +- Support for deprecated SSE transport with `/sse` (GET) and `/messages` (POST) +- Session type tracking to avoid mixing transport types +- Notifications and tool execution across both transport types + +```bash +npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts +``` + +### Multi-Node Deployment + +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 to MCP servers. Useful for simple API wrapper servers. +- **Persistent storage mode** - No local state needed, but session data is stored in a database. Example: an MCP server for online ordering where the shopping cart is stored in a database. +- **Local state with message routing** - Local state is needed, and all requests for a session must be routed to the correct node. This can be done with a message queue and pub/sub system. + +#### Stateless Mode + +The Streamable HTTP transport can be configured to operate without tracking sessions. This is perfect for simple API proxies or when each request is completely independent. + +##### Implementation + +To enable stateless mode, configure the `StreamableHTTPServerTransport` with: + +```typescript +sessionIdGenerator: undefined; +``` + +This disables session management entirely, and the server won't generate or expect session IDs. + +- No session ID headers are sent or expected +- Any server node can process any request +- No state is preserved between requests +- Perfect for RESTful or stateless API scenarios +- Simplest deployment model with minimal infrastructure requirements + +``` +┌─────────────────────────────────────────────┐ +│ Client │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────────┐ +│ MCP Server #1 │ │ MCP Server #2 │ +│ (Node.js) │ │ (Node.js) │ +└─────────────────┘ └─────────────────────┘ +``` + +#### Persistent Storage Mode + +For cases where you need session continuity but don't need to maintain in-memory state on specific nodes, you can use a database to persist session data while still allowing any node to handle requests. + +##### Implementation + +Configure the transport with session management, but retrieve and store all state in an external persistent storage: + +```typescript +sessionIdGenerator: () => randomUUID(), +eventStore: databaseEventStore +``` + +All session state is stored in the database, and any node can serve any client by retrieving the state when needed. + +- Maintains sessions with unique IDs +- Stores all session data in an external database +- Provides resumability through the database-backed EventStore +- Any node can handle any request for the same session +- No node-specific memory state means no need for message routing +- Good for applications where state can be fully externalized +- Somewhat higher latency due to database access for each request + +``` +┌─────────────────────────────────────────────┐ +│ 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 (such as Computer Use or complex session state), the Streamable HTTP transport can be combined with a pub/sub system to route messages to the correct node handling each session. + +1. **Bidirectional Message Queue Integration**: + - All nodes both publish to and subscribe from the message queue + - Each node registers the sessions it's actively handling + - Messages are routed based on session ownership + +2. **Request Handling Flow**: + - When a client connects to Node A with an existing `mcp-session-id` + - If Node A doesn't own this session, it: + - Establishes and maintains the SSE connection with the client + - Publishes the request to the message queue with the session ID + - Node B (which owns the session) receives the request from the queue + - Node B processes the request with its local session state + - Node B publishes responses/notifications back to the queue + - Node A subscribes to the response channel and forwards to the client + +3. **Channel Identification**: + - Each message channel combines both `mcp-session-id` and `stream-id` + - This ensures responses are correctly routed back to the originating connection + +``` +┌─────────────────────────────────────────────┐ +│ 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 │ +└─────────────────────────────────────────────┘ +``` + +- Maintains session affinity for stateful operations without client redirection +- Enables horizontal scaling while preserving complex in-memory state +- Provides fault tolerance through the message queue as intermediary + +## Backwards Compatibility + +### Testing Streamable HTTP Backwards Compatibility with SSE + +To test the backwards compatibility features: + +1. Start one of the server implementations: + + ```bash + # Legacy SSE server (protocol version 2024-11-05) + npx tsx src/examples/server/simpleSseServer.ts + + # Streamable HTTP server (protocol version 2025-11-25) + npx tsx src/examples/server/simpleStreamableHttp.ts + + # Backwards compatible server (supports both protocols) + npx tsx src/examples/server/sseAndStreamableHttpCompatibleServer.ts + ``` + +2. Then run the backwards compatible client: + ```bash + npx tsx src/examples/client/streamableHttpWithSseFallbackClient.ts + ``` + +This demonstrates how the MCP ecosystem ensures interoperability between clients and servers regardless of which protocol version they were built for. diff --git a/packages/examples/server/eslint.config.mjs b/packages/examples/server/eslint.config.mjs new file mode 100644 index 000000000..83b79879f --- /dev/null +++ b/packages/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/packages/examples/server/package.json b/packages/examples/server/package.json new file mode 100644 index 000000000..827b9a3d6 --- /dev/null +++ b/packages/examples/server/package.json @@ -0,0 +1,42 @@ +{ + "name": "@modelcontextprotocol/sdk-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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint test/ && prettier --check .", + "lint:fix": "eslint test/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/sdk-server": "workspace:^" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^" + } +} diff --git a/packages/examples/src/server/README-simpleTaskInteractive.md b/packages/examples/server/src/README-simpleTaskInteractive.md similarity index 100% rename from packages/examples/src/server/README-simpleTaskInteractive.md rename to packages/examples/server/src/README-simpleTaskInteractive.md diff --git a/packages/examples/src/server/demoInMemoryOAuthProvider.ts b/packages/examples/server/src/demoInMemoryOAuthProvider.ts similarity index 100% rename from packages/examples/src/server/demoInMemoryOAuthProvider.ts rename to packages/examples/server/src/demoInMemoryOAuthProvider.ts diff --git a/packages/examples/src/server/elicitationFormExample.ts b/packages/examples/server/src/elicitationFormExample.ts similarity index 100% rename from packages/examples/src/server/elicitationFormExample.ts rename to packages/examples/server/src/elicitationFormExample.ts diff --git a/packages/examples/src/server/elicitationUrlExample.ts b/packages/examples/server/src/elicitationUrlExample.ts similarity index 99% rename from packages/examples/src/server/elicitationUrlExample.ts rename to packages/examples/server/src/elicitationUrlExample.ts index 33153fbb9..37601a090 100644 --- a/packages/examples/src/server/elicitationUrlExample.ts +++ b/packages/examples/server/src/elicitationUrlExample.ts @@ -22,7 +22,7 @@ import { ElicitResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { InMemoryEventStore } from './inMemoryEventStore.js'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; @@ -263,7 +263,7 @@ const tokenVerifier = { throw new Error(`Invalid or expired token: ${text}`); } - const data = await response.json(); + const data = await response.json() as { aud: string; client_id: string; scope: string; exp: number }; if (!data.aud) { throw new Error(`Resource Indicator (RFC8707) missing`); diff --git a/packages/examples/src/shared/inMemoryEventStore.ts b/packages/examples/server/src/inMemoryEventStore.ts similarity index 97% rename from packages/examples/src/shared/inMemoryEventStore.ts rename to packages/examples/server/src/inMemoryEventStore.ts index 57c595afc..9612cfee6 100644 --- a/packages/examples/src/shared/inMemoryEventStore.ts +++ b/packages/examples/server/src/inMemoryEventStore.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessage } from '@modelcontextprotocol/sdk-server'; import type { EventStore } from '@modelcontextprotocol/sdk-server'; /** diff --git a/packages/examples/src/server/jsonResponseStreamableHttp.ts b/packages/examples/server/src/jsonResponseStreamableHttp.ts similarity index 100% rename from packages/examples/src/server/jsonResponseStreamableHttp.ts rename to packages/examples/server/src/jsonResponseStreamableHttp.ts diff --git a/packages/examples/src/server/mcpServerOutputSchema.ts b/packages/examples/server/src/mcpServerOutputSchema.ts similarity index 100% rename from packages/examples/src/server/mcpServerOutputSchema.ts rename to packages/examples/server/src/mcpServerOutputSchema.ts diff --git a/packages/examples/src/server/simpleSseServer.ts b/packages/examples/server/src/simpleSseServer.ts similarity index 100% rename from packages/examples/src/server/simpleSseServer.ts rename to packages/examples/server/src/simpleSseServer.ts diff --git a/packages/examples/src/server/simpleStatelessStreamableHttp.ts b/packages/examples/server/src/simpleStatelessStreamableHttp.ts similarity index 100% rename from packages/examples/src/server/simpleStatelessStreamableHttp.ts rename to packages/examples/server/src/simpleStatelessStreamableHttp.ts diff --git a/packages/examples/src/server/simpleStreamableHttp.ts b/packages/examples/server/src/simpleStreamableHttp.ts similarity index 99% rename from packages/examples/src/server/simpleStreamableHttp.ts rename to packages/examples/server/src/simpleStreamableHttp.ts index 45b22d620..00c47282a 100644 --- a/packages/examples/src/server/simpleStreamableHttp.ts +++ b/packages/examples/server/src/simpleStreamableHttp.ts @@ -15,7 +15,7 @@ import { ReadResourceResult, ResourceLink } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { InMemoryEventStore } from './inMemoryEventStore.js'; import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; @@ -546,7 +546,7 @@ if (useOAuth) { throw new Error(`Invalid or expired token: ${text}`); } - const data = await response.json(); + const data = await response.json() as { aud: string; client_id: string; scope: string; exp: number }; if (strictOAuth) { if (!data.aud) { diff --git a/packages/examples/src/server/simpleTaskInteractive.ts b/packages/examples/server/src/simpleTaskInteractive.ts similarity index 100% rename from packages/examples/src/server/simpleTaskInteractive.ts rename to packages/examples/server/src/simpleTaskInteractive.ts diff --git a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts similarity index 99% rename from packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts rename to packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts index 88bd335c6..febfa5a71 100644 --- a/packages/examples/src/server/sseAndStreamableHttpCompatibleServer.ts +++ b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts @@ -5,7 +5,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server' import { SSEServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { InMemoryEventStore } from './inMemoryEventStore.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; /** diff --git a/packages/examples/src/server/ssePollingExample.ts b/packages/examples/server/src/ssePollingExample.ts similarity index 98% rename from packages/examples/src/server/ssePollingExample.ts rename to packages/examples/server/src/ssePollingExample.ts index 21ef8b042..bb0d63068 100644 --- a/packages/examples/src/server/ssePollingExample.ts +++ b/packages/examples/server/src/ssePollingExample.ts @@ -18,7 +18,7 @@ import { McpServer } from '@modelcontextprotocol/sdk-server'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { CallToolResult } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; +import { InMemoryEventStore } from './inMemoryEventStore.js'; import cors from 'cors'; // Create the MCP server diff --git a/packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts b/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts similarity index 100% rename from packages/examples/src/server/standaloneSseWithGetStreamableHttp.ts rename to packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts diff --git a/packages/examples/src/server/toolWithSampleServer.ts b/packages/examples/server/src/toolWithSampleServer.ts similarity index 100% rename from packages/examples/src/server/toolWithSampleServer.ts rename to packages/examples/server/src/toolWithSampleServer.ts diff --git a/packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts b/packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts new file mode 100644 index 000000000..8bdd6d889 --- /dev/null +++ b/packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts @@ -0,0 +1,263 @@ +import { Response } from 'express'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../src/demoInMemoryOAuthProvider.js'; +import type { AuthorizationParams } from '../../../server/src/server/auth/provider.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; +import { InvalidRequestError } from '@modelcontextprotocol/sdk-core'; + +import { createExpressResponseMock } from '../../../integration/test/helpers/http.js'; + +describe('DemoInMemoryAuthProvider', () => { + let provider: DemoInMemoryAuthProvider; + let mockResponse: Response & { getRedirectUrl: () => string }; + + beforeEach(() => { + provider = new DemoInMemoryAuthProvider(); + mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { + getRedirectUrl: () => string; + }; + }); + + describe('authorize', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'], + scope: 'test-scope' + }; + + it('should redirect to the requested redirect_uri when valid', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + + expect(mockResponse.redirect).toHaveBeenCalled(); + expect(mockResponse.getRedirectUrl()).toBeDefined(); + + const url = new URL(mockResponse.getRedirectUrl()); + expect(url.origin + url.pathname).toBe('https://example.com/callback'); + expect(url.searchParams.get('state')).toBe('test-state'); + expect(url.searchParams.has('code')).toBe(true); + }); + + it('should throw InvalidRequestError for unregistered redirect_uri', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://evil.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError); + + await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri'); + + expect(mockResponse.redirect).not.toHaveBeenCalled(); + }); + + it('should generate unique authorization codes for multiple requests', async () => { + const params1: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'state-1', + codeChallenge: 'challenge-1', + scopes: ['test-scope'] + }; + + const params2: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'state-2', + codeChallenge: 'challenge-2', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params1, mockResponse); + const firstRedirectUrl = mockResponse.getRedirectUrl(); + const firstCode = new URL(firstRedirectUrl).searchParams.get('code'); + + // Reset the mock for the second call + mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { + getRedirectUrl: () => string; + }; + await provider.authorize(validClient, params2, mockResponse); + const secondRedirectUrl = mockResponse.getRedirectUrl(); + const secondCode = new URL(secondRedirectUrl).searchParams.get('code'); + + expect(firstCode).toBeDefined(); + expect(secondCode).toBeDefined(); + expect(firstCode).not.toBe(secondCode); + }); + + it('should handle params without state', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + + expect(mockResponse.redirect).toHaveBeenCalled(); + expect(mockResponse.getRedirectUrl()).toBeDefined(); + + const url = new URL(mockResponse.getRedirectUrl()); + expect(url.searchParams.has('state')).toBe(false); + expect(url.searchParams.has('code')).toBe(true); + }); + }); + + describe('challengeForAuthorizationCode', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + it('should return the code challenge for a valid authorization code', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge-value', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const challenge = await provider.challengeForAuthorizationCode(validClient, code); + expect(challenge).toBe('test-challenge-value'); + }); + + it('should throw error for invalid authorization code', async () => { + await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); + }); + }); + + describe('exchangeAuthorizationCode', () => { + const validClient: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + it('should exchange valid authorization code for tokens', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope', 'other-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode(validClient, code); + + expect(tokens).toEqual({ + access_token: expect.any(String), + token_type: 'bearer', + expires_in: 3600, + scope: 'test-scope other-scope' + }); + }); + + it('should throw error for invalid authorization code', async () => { + await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); + }); + + it('should throw error when client_id does not match', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + const differentClient: OAuthClientInformationFull = { + client_id: 'different-client', + client_secret: 'different-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow( + 'Authorization code was not issued to this client' + ); + }); + + it('should delete authorization code after successful exchange', async () => { + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'] + }; + + await provider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + // First exchange should succeed + await provider.exchangeAuthorizationCode(validClient, code); + + // Second exchange should fail + await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code'); + }); + + it('should validate resource when validateResource is provided', async () => { + const validateResource = vi.fn().mockReturnValue(false); + const strictProvider = new DemoInMemoryAuthProvider(validateResource); + + const params: AuthorizationParams = { + redirectUri: 'https://example.com/callback', + state: 'test-state', + codeChallenge: 'test-challenge', + scopes: ['test-scope'], + resource: new URL('https://invalid-resource.com') + }; + + await strictProvider.authorize(validClient, params, mockResponse); + const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; + + await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow( + 'Invalid resource: https://invalid-resource.com/' + ); + + expect(validateResource).toHaveBeenCalledWith(params.resource); + }); + }); + + describe('DemoInMemoryClientsStore', () => { + let store: DemoInMemoryClientsStore; + + beforeEach(() => { + store = new DemoInMemoryClientsStore(); + }); + + it('should register and retrieve client', async () => { + const client: OAuthClientInformationFull = { + client_id: 'test-client', + client_secret: 'test-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'test-scope' + }; + + await store.registerClient(client); + const retrieved = await store.getClient('test-client'); + + expect(retrieved).toEqual(client); + }); + + it('should return undefined for non-existent client', async () => { + const retrieved = await store.getClient('non-existent'); + expect(retrieved).toBeUndefined(); + }); + }); +}); diff --git a/packages/examples/server/tsconfig.build.json b/packages/examples/server/tsconfig.build.json new file mode 100644 index 000000000..eabb8d8ff --- /dev/null +++ b/packages/examples/server/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/examples/server/tsconfig.json b/packages/examples/server/tsconfig.json new file mode 100644 index 000000000..804705d8b --- /dev/null +++ b/packages/examples/server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["./*"], + "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], + "@modelcontextprotocol/sdk-core": [ + "node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/sdk-core/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/packages/examples/server/vitest.config.js b/packages/examples/server/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/examples/server/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/examples/tsconfig.json b/packages/examples/tsconfig.json deleted file mode 100644 index 7b67d98fc..000000000 --- a/packages/examples/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], - "exclude": ["node_modules", "dist"], - "compilerOptions": { - "paths": { - "*": ["./*"], - "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], - "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], - "@modelcontextprotocol/sdk-client": ["../client/src/index.ts"], - "@modelcontextprotocol/sdk-client/*": ["../client/src/*"], - "@modelcontextprotocol/sdk-server": ["../server/src/index.ts"], - "@modelcontextprotocol/sdk-server/*": ["../server/src/*"], - "@modelcontextprotocol/vitest-config": ["../common/vitest-config/tsconfig.json"], - "@modelcontextprotocol/eslint-config": ["../common/eslint-config/tsconfig.json"] - } - } -} diff --git a/packages/examples/vitest.config.js b/packages/examples/vitest.config.js deleted file mode 100644 index 42fc5dab4..000000000 --- a/packages/examples/vitest.config.js +++ /dev/null @@ -1,18 +0,0 @@ -import baseConfig from '@modelcontextprotocol/vitest-config'; -import { mergeConfig } from 'vitest/config'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export default mergeConfig(baseConfig, { - resolve: { - alias: { - // Use workspace source files instead of built dist/ for tests - '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), - '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), - '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') - } - } -}); diff --git a/packages/integration/package.json b/packages/integration/package.json index da4e9d4fe..fd584d581 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -39,12 +39,6 @@ ], "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", - "typecheck": "tsgo --noEmit", - "build": "npm run build:esm", - "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", - "build:esm:w": "npm run build:esm -- -w", - "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", - "prepack": "npm run build:esm && npm run build:cjs", "lint": "eslint test/ && prettier --check .", "lint:fix": "eslint test/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", diff --git a/packages/integration/tsconfig.json b/packages/integration/tsconfig.json index 2361b208c..63523d433 100644 --- a/packages/integration/tsconfig.json +++ b/packages/integration/tsconfig.json @@ -3,12 +3,13 @@ "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { + "baseUrl": ".", "paths": { "*": ["./*"], - "@modelcontextprotocol/sdk-core": ["./node_modules/@modelcontextprotocol/sdk-core/src/index.ts"], - "@modelcontextprotocol/sdk-client": ["./node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], - "@modelcontextprotocol/sdk-server": ["./node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@modelcontextprotocol/sdk-core": ["node_modules/@modelcontextprotocol/sdk-core/src/index.ts"], + "@modelcontextprotocol/sdk-client": ["node_modules/@modelcontextprotocol/sdk-client/src/index.ts"], + "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] } } } diff --git a/packages/integration/vitest.config.js b/packages/integration/vitest.config.js index 1a19ed409..38f030fdd 100644 --- a/packages/integration/vitest.config.js +++ b/packages/integration/vitest.config.js @@ -1,18 +1,3 @@ import baseConfig from '../../common/vitest-config/vitest.config.js'; -import { mergeConfig } from 'vitest/config'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export default mergeConfig(baseConfig, { - resolve: { - alias: { - // Use workspace source files instead of built dist/ for tests - '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts'), - '@modelcontextprotocol/sdk-client': path.resolve(__dirname, '../client/src/index.ts'), - '@modelcontextprotocol/sdk-server': path.resolve(__dirname, '../server/src/index.ts') - } - } -}); +export default baseConfig; diff --git a/packages/server/package.json b/packages/server/package.json index 7aecc1626..443aa652a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -35,12 +35,11 @@ ], "scripts": { "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", - "typecheck": "tsgo --noEmit", + "typecheck": "tsc -p tsconfig.build.json --noEmit", "build": "npm run build:esm", - "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.prod.json", + "build:esm": "mkdir -p dist && echo '{\"type\": \"module\"}' > dist/package.json && tsc -p tsconfig.build.json", "build:esm:w": "npm run build:esm -- -w", - "examples:simple-server:w": "tsx --watch src/examples/server/simpleStreamableHttp.ts --oauth", - "prepack": "npm run build:esm && npm run build:cjs", + "prepack": "npm run build:esm", "lint": "eslint src/ && prettier --check .", "lint:fix": "eslint src/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", @@ -92,7 +91,6 @@ "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.1.0", - "@types/node": "^22.12.0", "@types/supertest": "^6.0.2", "@types/ws": "^8.5.12", "@typescript/native-preview": "^7.0.0-dev.20251103.1", diff --git a/packages/server/src/experimental/index.js b/packages/server/src/experimental/index.js new file mode 100644 index 000000000..de0a7bc9b --- /dev/null +++ b/packages/server/src/experimental/index.js @@ -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'; +//# sourceMappingURL=index.js.map diff --git a/packages/server/src/experimental/tasks/index.js b/packages/server/src/experimental/tasks/index.js new file mode 100644 index 000000000..6d480deaf --- /dev/null +++ b/packages/server/src/experimental/tasks/index.js @@ -0,0 +1,12 @@ +/** + * Experimental task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ +// SDK implementation interfaces +export * from './interfaces.js'; +// Wrapper classes +export * from './server.js'; +export * from './mcp-server.js'; +//# sourceMappingURL=index.js.map diff --git a/packages/server/src/experimental/tasks/interfaces.js b/packages/server/src/experimental/tasks/interfaces.js new file mode 100644 index 000000000..b57b748c6 --- /dev/null +++ b/packages/server/src/experimental/tasks/interfaces.js @@ -0,0 +1,6 @@ +/** + * Experimental task interfaces for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + */ +export {}; +//# sourceMappingURL=interfaces.js.map diff --git a/packages/server/src/experimental/tasks/mcp-server.js b/packages/server/src/experimental/tasks/mcp-server.js new file mode 100644 index 000000000..645878ed2 --- /dev/null +++ b/packages/server/src/experimental/tasks/mcp-server.js @@ -0,0 +1,43 @@ +/** + * Experimental McpServer task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ +/** + * Experimental task features for McpServer. + * + * Access via `server.experimental.tasks`: + * ```typescript + * server.experimental.tasks.registerToolTask('long-running', config, handler); + * ``` + * + * @experimental + */ +export class ExperimentalMcpServerTasks { + _mcpServer; + constructor(_mcpServer) { + this._mcpServer = _mcpServer; + } + registerToolTask(name, config, handler) { + // Validate that taskSupport is not 'forbidden' for task-based tools + const execution = { 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; + return mcpServerInternal._createRegisteredTool( + name, + config.title, + config.description, + config.inputSchema, + config.outputSchema, + config.annotations, + execution, + config._meta, + handler + ); + } +} +//# sourceMappingURL=mcp-server.js.map diff --git a/packages/server/src/experimental/tasks/server.js b/packages/server/src/experimental/tasks/server.js new file mode 100644 index 000000000..38f956338 --- /dev/null +++ b/packages/server/src/experimental/tasks/server.js @@ -0,0 +1,90 @@ +/** + * Experimental server task features for MCP SDK. + * WARNING: These APIs are experimental and may change without notice. + * + * @experimental + */ +/** + * Experimental task features for low-level MCP servers. + * + * Access via `server.experimental.tasks`: + * ```typescript + * const stream = server.experimental.tasks.requestStream(request, schema, options); + * ``` + * + * For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead. + * + * @experimental + */ +export class ExperimentalServerTasks { + _server; + constructor(_server) { + this._server = _server; + } + /** + * 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 + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options (timeout, signal, task creation params, etc.) + * @returns AsyncGenerator that yields ResponseMessage objects + * + * @experimental + */ + requestStream(request, resultSchema, options) { + return this._server.requestStream(request, resultSchema, options); + } + /** + * 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, options) { + return this._server.getTask({ taskId }, options); + } + /** + * Retrieves the result of a completed task. + * + * @param taskId - The task identifier + * @param resultSchema - Zod schema for validating the result + * @param options - Optional request options + * @returns The task result + * + * @experimental + */ + async getTaskResult(taskId, resultSchema, options) { + return this._server.getTaskResult({ taskId }, resultSchema, 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, options) { + return this._server.listTasks(cursor ? { cursor } : undefined, options); + } + /** + * Cancels a running task. + * + * @param taskId - The task identifier + * @param options - Optional request options + * + * @experimental + */ + async cancelTask(taskId, options) { + return this._server.cancelTask({ taskId }, options); + } +} +//# sourceMappingURL=server.js.map diff --git a/packages/server/src/server/middleware/hostHeaderValidation.js b/packages/server/src/server/middleware/hostHeaderValidation.js new file mode 100644 index 000000000..0bc99cc88 --- /dev/null +++ b/packages/server/src/server/middleware/hostHeaderValidation.js @@ -0,0 +1,75 @@ +/** + * 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 + * ```typescript + * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * app.use(middleware); + * ``` + */ +export function hostHeaderValidation(allowedHostnames) { + return (req, res, next) => { + const hostHeader = req.headers.host; + if (!hostHeader) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Missing Host header' + }, + id: null + }); + return; + } + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host header: ${hostHeader}` + }, + id: null + }); + return; + } + if (!allowedHostnames.includes(hostname)) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host: ${hostname}` + }, + id: null + }); + return; + } + next(); + }; +} +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. + * + * @example + * ```typescript + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation() { + return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); +} +//# sourceMappingURL=hostHeaderValidation.js.map diff --git a/tsconfig.prod.json b/packages/server/tsconfig.build.json similarity index 85% rename from tsconfig.prod.json rename to packages/server/tsconfig.build.json index 82710bd6a..15f816be5 100644 --- a/tsconfig.prod.json +++ b/packages/server/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "./dist/esm" + "outDir": "./dist" }, "include": ["src/**/*"], "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index d42dc849f..c0986de61 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -3,11 +3,10 @@ "include": ["./"], "exclude": ["node_modules", "dist"], "compilerOptions": { + "baseUrl": ".", "paths": { "*": ["./*"], - "@modelcontextprotocol/sdk-core": ["../core/src/index.ts"], - "@modelcontextprotocol/sdk-core/*": ["../core/src/*"], - "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@modelcontextprotocol/sdk-core": ["node_modules/@modelcontextprotocol/sdk-core/src/index.ts"] } } } diff --git a/packages/server/vitest.config.js b/packages/server/vitest.config.js index cd4859e3f..496fca320 100644 --- a/packages/server/vitest.config.js +++ b/packages/server/vitest.config.js @@ -1,16 +1,3 @@ -import baseConfig from '../../common/vitest-config/vitest.config.js'; -import { mergeConfig } from 'vitest/config'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import baseConfig from '@modelcontextprotocol/vitest-config'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export default mergeConfig(baseConfig, { - resolve: { - alias: { - // Use workspace source files instead of built dist/ for tests - '@modelcontextprotocol/sdk-core': path.resolve(__dirname, '../core/src/index.ts') - } - } -}); +export default baseConfig; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f37b0fb1..d4e5b80bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,8 +88,8 @@ importers: specifier: ^5.1.0 version: 5.1.0 '@types/node': - specifier: ^22.12.0 - version: 22.19.0 + specifier: ^24.10.1 + version: 24.10.3 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -118,14 +118,14 @@ importers: specifier: ^4.16.5 version: 4.20.6 typescript: - specifier: ^5.5.4 + specifier: ^5.9.3 version: 5.9.3 typescript-eslint: specifier: ^8.48.1 version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) vitest: specifier: ^4.0.8 - version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) ws: specifier: ^8.18.0 version: 8.18.3 @@ -170,6 +170,9 @@ importers: '@modelcontextprotocol/tsconfig': specifier: workspace:^ version: link:../tsconfig + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6)) packages/client: dependencies: @@ -255,9 +258,6 @@ importers: '@types/express-serve-static-core': specifier: ^5.1.0 version: 5.1.0 - '@types/node': - specifier: ^22.12.0 - version: 22.19.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -293,11 +293,13 @@ importers: version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) vitest: specifier: ^4.0.8 - version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) ws: specifier: ^8.18.0 version: 8.18.3 + packages/client/dist: {} + packages/core: dependencies: ajv: @@ -379,9 +381,6 @@ importers: '@types/express-serve-static-core': specifier: ^5.1.0 version: 5.1.0 - '@types/node': - specifier: ^22.12.0 - version: 22.19.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -417,29 +416,44 @@ importers: version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) vitest: specifier: ^4.0.8 - version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) ws: specifier: ^8.18.0 version: 8.18.3 - packages/examples: + packages/core/dist: {} + + packages/examples/client: dependencies: '@modelcontextprotocol/sdk-client': specifier: workspace:^ - version: link:../client + version: link:../../client + 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 + + packages/examples/server: + dependencies: '@modelcontextprotocol/sdk-server': specifier: workspace:^ - version: link:../server + version: link:../../server devDependencies: '@modelcontextprotocol/eslint-config': specifier: workspace:^ - version: link:../../common/eslint-config + version: link:../../../common/eslint-config '@modelcontextprotocol/tsconfig': specifier: workspace:^ - version: link:../../common/tsconfig + version: link:../../../common/tsconfig '@modelcontextprotocol/vitest-config': specifier: workspace:^ - version: link:../../common/vitest-config + version: link:../../../common/vitest-config packages/integration: devDependencies: @@ -546,9 +560,6 @@ importers: '@types/express-serve-static-core': specifier: ^5.1.0 version: 5.1.0 - '@types/node': - specifier: ^22.12.0 - version: 22.19.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -584,11 +595,13 @@ importers: version: 8.49.0(eslint@9.39.1)(typescript@5.9.3) vitest: specifier: ^4.0.8 - version: 4.0.9(@types/node@22.19.0)(tsx@4.20.6) + version: 4.0.9(@types/node@24.10.3)(tsx@4.20.6) ws: specifier: ^8.18.0 version: 8.18.3 + packages/server/dist: {} + packages: '@cfworker/json-schema@4.1.1': @@ -975,8 +988,8 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@22.19.0': - resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + '@types/node@24.10.3': + resolution: {integrity: sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1911,6 +1924,16 @@ packages: peerDependencies: typescript: '>=4.0.0' + 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 + tsx@4.20.6: resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} @@ -1936,8 +1959,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -1950,6 +1973,14 @@ packages: 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.2.2: resolution: {integrity: sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2283,7 +2314,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/chai@5.2.3': dependencies: @@ -2292,7 +2323,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/content-type@1.1.9': {} @@ -2300,11 +2331,11 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/deep-eql@4.0.2': {} @@ -2314,7 +2345,7 @@ snapshots: '@types/express-serve-static-core@5.1.0': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -2333,9 +2364,9 @@ snapshots: '@types/mime@1.3.5': {} - '@types/node@22.19.0': + '@types/node@24.10.3': dependencies: - undici-types: 6.21.0 + undici-types: 7.16.0 '@types/qs@6.14.0': {} @@ -2344,23 +2375,23 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/send@1.2.1': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@types/send': 0.17.6 '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 22.19.0 + '@types/node': 24.10.3 form-data: 4.0.4 '@types/supertest@6.0.3': @@ -2370,7 +2401,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': dependencies: @@ -2503,13 +2534,13 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.9(vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6))': + '@vitest/mocker@4.0.9(vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6))': dependencies: '@vitest/spy': 4.0.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.2(@types/node@22.19.0)(tsx@4.20.6) + vite: 7.2.2(@types/node@24.10.3)(tsx@4.20.6) '@vitest/pretty-format@4.0.9': dependencies: @@ -3363,6 +3394,10 @@ snapshots: picomatch: 4.0.3 typescript: 5.9.3 + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + tsx@4.20.6: dependencies: esbuild: 0.25.12 @@ -3393,7 +3428,7 @@ snapshots: typescript@5.9.3: {} - undici-types@6.21.0: {} + undici-types@7.16.0: {} unpipe@1.0.0: {} @@ -3403,7 +3438,18 @@ snapshots: vary@1.1.2: {} - vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: 7.2.2(@types/node@24.10.3)(tsx@4.20.6) + transitivePeerDependencies: + - supports-color + - typescript + + vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -3412,14 +3458,14 @@ snapshots: rollup: 4.53.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 fsevents: 2.3.3 tsx: 4.20.6 - vitest@4.0.9(@types/node@22.19.0)(tsx@4.20.6): + vitest@4.0.9(@types/node@24.10.3)(tsx@4.20.6): dependencies: '@vitest/expect': 4.0.9 - '@vitest/mocker': 4.0.9(vite@7.2.2(@types/node@22.19.0)(tsx@4.20.6)) + '@vitest/mocker': 4.0.9(vite@7.2.2(@types/node@24.10.3)(tsx@4.20.6)) '@vitest/pretty-format': 4.0.9 '@vitest/runner': 4.0.9 '@vitest/snapshot': 4.0.9 @@ -3436,10 +3482,10 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.2(@types/node@22.19.0)(tsx@4.20.6) + vite: 7.2.2(@types/node@24.10.3)(tsx@4.20.6) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.0 + '@types/node': 24.10.3 transitivePeerDependencies: - jiti - less diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json deleted file mode 100644 index 4b712da77..000000000 --- a/tsconfig.cjs.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node", - "outDir": "./dist/cjs" - }, - "include": ["src/**/*"], - "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/__fixtures__/**/*"] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index f283689f1..000000000 --- a/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - environment: 'node', - setupFiles: ['./vitest.setup.ts'], - include: ['test/**/*.test.ts'] - } -}); From 647e5f043ac229c696519f1de27b0eb24ab398b8 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 11:55:17 +0200 Subject: [PATCH 09/22] save commit --- packages/client/src/client/sse.ts | 2 +- packages/client/src/client/streamableHttp.ts | 2 +- packages/core/src/shared/transport.ts | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index e886430a6..e03ee1109 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -114,7 +114,7 @@ export class SSEClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: Record = {}; + const headers: RequestInit['headers'] & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index d91e879ac..e67360c71 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -187,7 +187,7 @@ export class StreamableHTTPClientTransport implements Transport { } private async _commonHeaders(): Promise { - const headers: Record = {}; + const headers: RequestInit['headers'] & Record = {}; if (this._authProvider) { const tokens = await this._authProvider.tokens(); if (tokens) { diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index bb65ac83b..e20cb6f09 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,5 +1,4 @@ import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/types.js'; -import type { HeadersInit } from 'undici-types'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -7,7 +6,7 @@ export type FetchLike = (url: string | URL, init?: RequestInit) => Promise for manipulation. * Handles Headers objects, arrays of tuples, and plain objects. */ -export function normalizeHeaders(headers: HeadersInit | undefined): Record { +export function normalizeHeaders(headers: RequestInit['headers'] | undefined): Record { if (!headers) return {}; if (headers instanceof Headers) { From 6c0818f0d2d8b2412914b39834b4932a378cffee Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:03:53 +0200 Subject: [PATCH 10/22] lint fix --- packages/examples/client/package.json | 7 +- .../server/demoInMemoryOAuthProvider.test.ts | 263 ------------------ packages/examples/client/tsconfig.json | 5 +- packages/examples/server/package.json | 6 +- .../server/src/elicitationUrlExample.ts | 4 +- .../server/src/simpleStreamableHttp.ts | 4 +- packages/examples/server/tsconfig.json | 2 +- packages/examples/shared/eslint.config.mjs | 14 + packages/examples/shared/package.json | 42 +++ .../src/demoInMemoryOAuthProvider.ts | 0 packages/examples/shared/src/index.ts | 1 + .../test}/demoInMemoryOAuthProvider.test.ts | 4 +- packages/examples/shared/tsconfig.build.json | 9 + packages/examples/shared/tsconfig.json | 17 ++ packages/examples/shared/vitest.config.js | 3 + .../server/middleware/hostHeaderValidation.js | 2 + pnpm-lock.yaml | 19 ++ 17 files changed, 122 insertions(+), 280 deletions(-) delete mode 100644 packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts create mode 100644 packages/examples/shared/eslint.config.mjs create mode 100644 packages/examples/shared/package.json rename packages/examples/{server => shared}/src/demoInMemoryOAuthProvider.ts (100%) create mode 100644 packages/examples/shared/src/index.ts rename packages/examples/{server/test/server => shared/test}/demoInMemoryOAuthProvider.test.ts (98%) create mode 100644 packages/examples/shared/tsconfig.build.json create mode 100644 packages/examples/shared/tsconfig.json create mode 100644 packages/examples/shared/vitest.config.js diff --git a/packages/examples/client/package.json b/packages/examples/client/package.json index d4357c6ab..e46e2aff8 100644 --- a/packages/examples/client/package.json +++ b/packages/examples/client/package.json @@ -22,11 +22,9 @@ "scripts": { "typecheck": "tsc -p tsconfig.build.json --noEmit", "prepack": "npm run build:esm && npm run build:cjs", - "lint": "eslint test/ && prettier --check .", - "lint:fix": "eslint test/ --fix && prettier --write .", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" @@ -35,6 +33,7 @@ "@modelcontextprotocol/sdk-client": "workspace:^" }, "devDependencies": { + "@modelcontextprotocol/sdk-examples-shared": "workspace:^", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/eslint-config": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^" diff --git a/packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts b/packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts deleted file mode 100644 index b67690873..000000000 --- a/packages/examples/client/test/server/demoInMemoryOAuthProvider.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { Response } from 'express'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../src/server/demoInMemoryOAuthProvider.js'; -import type { AuthorizationParams } from '../../../server/src/server/auth/provider.js'; -import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; -import { InvalidRequestError } from '@modelcontextprotocol/sdk-core'; - -import { createExpressResponseMock } from '../../../integration/test/helpers/http.js'; - -describe('DemoInMemoryAuthProvider', () => { - let provider: DemoInMemoryAuthProvider; - let mockResponse: Response & { getRedirectUrl: () => string }; - - beforeEach(() => { - provider = new DemoInMemoryAuthProvider(); - mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { - getRedirectUrl: () => string; - }; - }); - - describe('authorize', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback', 'https://example.com/callback2'], - scope: 'test-scope' - }; - - it('should redirect to the requested redirect_uri when valid', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - - expect(mockResponse.redirect).toHaveBeenCalled(); - expect(mockResponse.getRedirectUrl()).toBeDefined(); - - const url = new URL(mockResponse.getRedirectUrl()); - expect(url.origin + url.pathname).toBe('https://example.com/callback'); - expect(url.searchParams.get('state')).toBe('test-state'); - expect(url.searchParams.has('code')).toBe(true); - }); - - it('should throw InvalidRequestError for unregistered redirect_uri', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://evil.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow(InvalidRequestError); - - await expect(provider.authorize(validClient, params, mockResponse)).rejects.toThrow('Unregistered redirect_uri'); - - expect(mockResponse.redirect).not.toHaveBeenCalled(); - }); - - it('should generate unique authorization codes for multiple requests', async () => { - const params1: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'state-1', - codeChallenge: 'challenge-1', - scopes: ['test-scope'] - }; - - const params2: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'state-2', - codeChallenge: 'challenge-2', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params1, mockResponse); - const firstRedirectUrl = mockResponse.getRedirectUrl(); - const firstCode = new URL(firstRedirectUrl).searchParams.get('code'); - - // Reset the mock for the second call - mockResponse = createExpressResponseMock({ trackRedirectUrl: true }) as Response & { - getRedirectUrl: () => string; - }; - await provider.authorize(validClient, params2, mockResponse); - const secondRedirectUrl = mockResponse.getRedirectUrl(); - const secondCode = new URL(secondRedirectUrl).searchParams.get('code'); - - expect(firstCode).toBeDefined(); - expect(secondCode).toBeDefined(); - expect(firstCode).not.toBe(secondCode); - }); - - it('should handle params without state', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - - expect(mockResponse.redirect).toHaveBeenCalled(); - expect(mockResponse.getRedirectUrl()).toBeDefined(); - - const url = new URL(mockResponse.getRedirectUrl()); - expect(url.searchParams.has('state')).toBe(false); - expect(url.searchParams.has('code')).toBe(true); - }); - }); - - describe('challengeForAuthorizationCode', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - it('should return the code challenge for a valid authorization code', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge-value', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const challenge = await provider.challengeForAuthorizationCode(validClient, code); - expect(challenge).toBe('test-challenge-value'); - }); - - it('should throw error for invalid authorization code', async () => { - await expect(provider.challengeForAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); - }); - }); - - describe('exchangeAuthorizationCode', () => { - const validClient: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - it('should exchange valid authorization code for tokens', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope', 'other-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode(validClient, code); - - expect(tokens).toEqual({ - access_token: expect.any(String), - token_type: 'bearer', - expires_in: 3600, - scope: 'test-scope other-scope' - }); - }); - - it('should throw error for invalid authorization code', async () => { - await expect(provider.exchangeAuthorizationCode(validClient, 'invalid-code')).rejects.toThrow('Invalid authorization code'); - }); - - it('should throw error when client_id does not match', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - const differentClient: OAuthClientInformationFull = { - client_id: 'different-client', - client_secret: 'different-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - await expect(provider.exchangeAuthorizationCode(differentClient, code)).rejects.toThrow( - 'Authorization code was not issued to this client' - ); - }); - - it('should delete authorization code after successful exchange', async () => { - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'] - }; - - await provider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - // First exchange should succeed - await provider.exchangeAuthorizationCode(validClient, code); - - // Second exchange should fail - await expect(provider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow('Invalid authorization code'); - }); - - it('should validate resource when validateResource is provided', async () => { - const validateResource = vi.fn().mockReturnValue(false); - const strictProvider = new DemoInMemoryAuthProvider(validateResource); - - const params: AuthorizationParams = { - redirectUri: 'https://example.com/callback', - state: 'test-state', - codeChallenge: 'test-challenge', - scopes: ['test-scope'], - resource: new URL('https://invalid-resource.com') - }; - - await strictProvider.authorize(validClient, params, mockResponse); - const code = new URL(mockResponse.getRedirectUrl()).searchParams.get('code')!; - - await expect(strictProvider.exchangeAuthorizationCode(validClient, code)).rejects.toThrow( - 'Invalid resource: https://invalid-resource.com/' - ); - - expect(validateResource).toHaveBeenCalledWith(params.resource); - }); - }); - - describe('DemoInMemoryClientsStore', () => { - let store: DemoInMemoryClientsStore; - - beforeEach(() => { - store = new DemoInMemoryClientsStore(); - }); - - it('should register and retrieve client', async () => { - const client: OAuthClientInformationFull = { - client_id: 'test-client', - client_secret: 'test-secret', - redirect_uris: ['https://example.com/callback'], - scope: 'test-scope' - }; - - await store.registerClient(client); - const retrieved = await store.getClient('test-client'); - - expect(retrieved).toEqual(client); - }); - - it('should return undefined for non-existent client', async () => { - const retrieved = await store.getClient('non-existent'); - expect(retrieved).toBeUndefined(); - }); - }); -}); diff --git a/packages/examples/client/tsconfig.json b/packages/examples/client/tsconfig.json index 2c3172ef1..80eb913eb 100644 --- a/packages/examples/client/tsconfig.json +++ b/packages/examples/client/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], + "include": ["./", "../shared/test/demoInMemoryOAuthProvider.test.ts"], "exclude": ["node_modules", "dist"], "compilerOptions": { "baseUrl": ".", @@ -11,7 +11,8 @@ "node_modules/@modelcontextprotocol/sdk-client/node_modules/@modelcontextprotocol/sdk-core/src/index.ts" ], "@modelcontextprotocol/eslint-config": ["node_modules/@modelcontextprotocol/eslint-config/tsconfig.json"], - "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"] + "@modelcontextprotocol/vitest-config": ["node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], + "@modelcontextprotocol/sdk-examples-shared": ["node_modules/@modelcontextprotocol/sdk-examples-shared/src/index.ts"] } } } diff --git a/packages/examples/server/package.json b/packages/examples/server/package.json index 827b9a3d6..8d56671b9 100644 --- a/packages/examples/server/package.json +++ b/packages/examples/server/package.json @@ -22,11 +22,9 @@ "scripts": { "typecheck": "tsc -p tsconfig.build.json --noEmit", "prepack": "npm run build:esm && npm run build:cjs", - "lint": "eslint test/ && prettier --check .", - "lint:fix": "eslint test/ --fix && prettier --write .", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" diff --git a/packages/examples/server/src/elicitationUrlExample.ts b/packages/examples/server/src/elicitationUrlExample.ts index 37601a090..7c423ba4c 100644 --- a/packages/examples/server/src/elicitationUrlExample.ts +++ b/packages/examples/server/src/elicitationUrlExample.ts @@ -23,7 +23,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { setupAuthServer } from '../../shared/src/demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; @@ -263,7 +263,7 @@ const tokenVerifier = { throw new Error(`Invalid or expired token: ${text}`); } - const data = await response.json() as { aud: string; client_id: string; scope: string; exp: number }; + const data = (await response.json()) as { aud: string; client_id: string; scope: string; exp: number }; if (!data.aud) { throw new Error(`Resource Indicator (RFC8707) missing`); diff --git a/packages/examples/server/src/simpleStreamableHttp.ts b/packages/examples/server/src/simpleStreamableHttp.ts index 00c47282a..ea0f69e56 100644 --- a/packages/examples/server/src/simpleStreamableHttp.ts +++ b/packages/examples/server/src/simpleStreamableHttp.ts @@ -17,7 +17,7 @@ import { } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; -import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; +import { setupAuthServer } from '../../shared/src/demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; @@ -546,7 +546,7 @@ if (useOAuth) { throw new Error(`Invalid or expired token: ${text}`); } - const data = await response.json() as { aud: string; client_id: string; scope: string; exp: number }; + const data = (await response.json()) as { aud: string; client_id: string; scope: string; exp: number }; if (strictOAuth) { if (!data.aud) { diff --git a/packages/examples/server/tsconfig.json b/packages/examples/server/tsconfig.json index 804705d8b..849ce5b5b 100644 --- a/packages/examples/server/tsconfig.json +++ b/packages/examples/server/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "@modelcontextprotocol/tsconfig", - "include": ["./"], + "include": ["./", "../shared/src/demoInMemoryOAuthProvider.ts"], "exclude": ["node_modules", "dist"], "compilerOptions": { "baseUrl": ".", diff --git a/packages/examples/shared/eslint.config.mjs b/packages/examples/shared/eslint.config.mjs new file mode 100644 index 000000000..83b79879f --- /dev/null +++ b/packages/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/packages/examples/shared/package.json b/packages/examples/shared/package.json new file mode 100644 index 000000000..3295f70c5 --- /dev/null +++ b/packages/examples/shared/package.json @@ -0,0 +1,42 @@ +{ + "name": "@modelcontextprotocol/sdk-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": ">=18" + }, + "keywords": [ + "modelcontextprotocol", + "mcp" + ], + "scripts": { + "typecheck": "tsc -p tsconfig.build.json --noEmit", + "prepack": "npm run build:esm && npm run build:cjs", + "lint": "eslint src/ && prettier --check .", + "lint:fix": "eslint src/ --fix && prettier --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" + }, + "dependencies": { + "@modelcontextprotocol/sdk-server": "workspace:^" + }, + "devDependencies": { + "@modelcontextprotocol/tsconfig": "workspace:^", + "@modelcontextprotocol/eslint-config": "workspace:^", + "@modelcontextprotocol/vitest-config": "workspace:^" + } +} diff --git a/packages/examples/server/src/demoInMemoryOAuthProvider.ts b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts similarity index 100% rename from packages/examples/server/src/demoInMemoryOAuthProvider.ts rename to packages/examples/shared/src/demoInMemoryOAuthProvider.ts diff --git a/packages/examples/shared/src/index.ts b/packages/examples/shared/src/index.ts new file mode 100644 index 000000000..1c31cf06e --- /dev/null +++ b/packages/examples/shared/src/index.ts @@ -0,0 +1 @@ +export * from './demoInMemoryOAuthProvider.js'; diff --git a/packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts b/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts similarity index 98% rename from packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts rename to packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts index 8bdd6d889..6909ca61e 100644 --- a/packages/examples/server/test/server/demoInMemoryOAuthProvider.test.ts +++ b/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts @@ -1,6 +1,6 @@ import { Response } from 'express'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../../src/demoInMemoryOAuthProvider.js'; -import type { AuthorizationParams } from '../../../server/src/server/auth/provider.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../src/demoInMemoryOAuthProvider.js'; +import type { AuthorizationParams } from '@modelcontextprotocol/sdk-server'; import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; import { InvalidRequestError } from '@modelcontextprotocol/sdk-core'; diff --git a/packages/examples/shared/tsconfig.build.json b/packages/examples/shared/tsconfig.build.json new file mode 100644 index 000000000..eabb8d8ff --- /dev/null +++ b/packages/examples/shared/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./"], + "exclude": ["dist", "node_modules", "test"] +} diff --git a/packages/examples/shared/tsconfig.json b/packages/examples/shared/tsconfig.json new file mode 100644 index 000000000..804705d8b --- /dev/null +++ b/packages/examples/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@modelcontextprotocol/tsconfig", + "include": ["./"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "*": ["./*"], + "@modelcontextprotocol/sdk-server": ["node_modules/@modelcontextprotocol/sdk-server/src/index.ts"], + "@modelcontextprotocol/sdk-core": [ + "node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/sdk-core/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/packages/examples/shared/vitest.config.js b/packages/examples/shared/vitest.config.js new file mode 100644 index 000000000..496fca320 --- /dev/null +++ b/packages/examples/shared/vitest.config.js @@ -0,0 +1,3 @@ +import baseConfig from '@modelcontextprotocol/vitest-config'; + +export default baseConfig; diff --git a/packages/server/src/server/middleware/hostHeaderValidation.js b/packages/server/src/server/middleware/hostHeaderValidation.js index 0bc99cc88..6ef065074 100644 --- a/packages/server/src/server/middleware/hostHeaderValidation.js +++ b/packages/server/src/server/middleware/hostHeaderValidation.js @@ -1,3 +1,5 @@ +import { URL } from 'node:url'; + /** * Express middleware for DNS rebinding protection. * Validates Host header hostname (port-agnostic) against an allowed list. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4e5b80bb..8a778214e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,6 +432,9 @@ importers: '@modelcontextprotocol/eslint-config': specifier: workspace:^ version: link:../../../common/eslint-config + '@modelcontextprotocol/sdk-examples-shared': + specifier: workspace:^ + version: link:../shared '@modelcontextprotocol/tsconfig': specifier: workspace:^ version: link:../../../common/tsconfig @@ -455,6 +458,22 @@ importers: specifier: workspace:^ version: link:../../../common/vitest-config + packages/examples/shared: + dependencies: + '@modelcontextprotocol/sdk-server': + specifier: workspace:^ + version: link:../../server + 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 + packages/integration: devDependencies: '@modelcontextprotocol/eslint-config': From ea354fc22902384ca0ac9356515a1a776c5ea735 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:11:20 +0200 Subject: [PATCH 11/22] main.yml fix --- .github/workflows/main.yml | 58 ++++++++------------------------------ 1 file changed, 12 insertions(+), 46 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6e64b0c9a..459fa15d5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,26 +20,13 @@ jobs: - uses: actions/setup-node@v6 with: node-version: 24 - cache: npm + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install with: run_install: false - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - ${{ runner.os }}-pnpm-store- - uses: actions/cache@v4 name: Retrieve Cache @@ -69,39 +56,14 @@ jobs: - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - cache: npm + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install with: run_install: false - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - name: Setup pnpm cache - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - ${{ runner.os }}-pnpm-store- - - - uses: actions/cache@v4 - name: Retrieve Cache - with: - path: | - node_modules/.cache - node_modules/.vitest - key: ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} - ${{ runner.os }}-build- - ${{ runner.os }}- - run: pnpm test:all publish: @@ -119,10 +81,14 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 24 - cache: npm + cache: pnpm registry-url: 'https://registry.npmjs.org' - - - run: npm ci + - name: Install pnpm + uses: pnpm/action-setup@v4 + id: pnpm-install + with: + run_install: false + - run: pnpm install - name: Determine npm tag id: npm-tag @@ -145,6 +111,6 @@ jobs: echo "tag=" >> $GITHUB_OUTPUT fi - - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} + - run: pnpm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 951a2341c582fdde5d6640fc3635f826acefe8ac Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:16:39 +0200 Subject: [PATCH 12/22] main.yml fix --- .github/workflows/main.yml | 45 ++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 459fa15d5..fbcfc8148 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,28 +17,18 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install with: run_install: false - - - uses: actions/cache@v4 - name: Retrieve Cache + - uses: actions/setup-node@v6 with: - path: | - node_modules/.cache - node_modules/.vitest - key: ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }} - ${{ runner.os }}-build- - ${{ runner.os }}- + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - run: pnpm install - run: pnpm run check:all @@ -53,17 +43,18 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node-version }} - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - name: Install pnpm uses: pnpm/action-setup@v4 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 test:all publish: @@ -78,16 +69,18 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - registry-url: 'https://registry.npmjs.org' + - name: Install pnpm uses: pnpm/action-setup@v4 id: pnpm-install with: run_install: false + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + registry-url: 'https://registry.npmjs.org' - run: pnpm install - name: Determine npm tag From a7e60b9316cc2b349eadcbd28e3a5f51e4543cd1 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:19:38 +0200 Subject: [PATCH 13/22] add packageManager in package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 058520f57..7911a7490 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "engines": { "node": ">=20" }, + "packageManager": "pnpm@10.24.0", "keywords": [ "modelcontextprotocol", "mcp" From 9d1e86c54053e30a1fe791b633aa427dbf412797 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:32:51 +0200 Subject: [PATCH 14/22] typecheck fix, test main.yml fix --- .github/workflows/main.yml | 2 ++ packages/examples/server/package.json | 3 ++- packages/examples/server/src/elicitationUrlExample.ts | 2 +- packages/examples/server/src/simpleStreamableHttp.ts | 2 +- packages/examples/server/tsconfig.json | 1 + pnpm-lock.yaml | 3 +++ 6 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fbcfc8148..aae940789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,6 +55,8 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml + - run: pnpm install + - run: pnpm test:all publish: diff --git a/packages/examples/server/package.json b/packages/examples/server/package.json index 8d56671b9..66811b740 100644 --- a/packages/examples/server/package.json +++ b/packages/examples/server/package.json @@ -30,7 +30,8 @@ "client": "tsx scripts/cli.ts client" }, "dependencies": { - "@modelcontextprotocol/sdk-server": "workspace:^" + "@modelcontextprotocol/sdk-server": "workspace:^", + "@modelcontextprotocol/sdk-examples-shared": "workspace:^" }, "devDependencies": { "@modelcontextprotocol/tsconfig": "workspace:^", diff --git a/packages/examples/server/src/elicitationUrlExample.ts b/packages/examples/server/src/elicitationUrlExample.ts index 7c423ba4c..8a832e0d5 100644 --- a/packages/examples/server/src/elicitationUrlExample.ts +++ b/packages/examples/server/src/elicitationUrlExample.ts @@ -23,7 +23,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { setupAuthServer } from '../../shared/src/demoInMemoryOAuthProvider.js'; +import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; diff --git a/packages/examples/server/src/simpleStreamableHttp.ts b/packages/examples/server/src/simpleStreamableHttp.ts index ea0f69e56..83dc3568f 100644 --- a/packages/examples/server/src/simpleStreamableHttp.ts +++ b/packages/examples/server/src/simpleStreamableHttp.ts @@ -17,7 +17,7 @@ import { } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; -import { setupAuthServer } from '../../shared/src/demoInMemoryOAuthProvider.js'; +import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; diff --git a/packages/examples/server/tsconfig.json b/packages/examples/server/tsconfig.json index 849ce5b5b..95df1a35a 100644 --- a/packages/examples/server/tsconfig.json +++ b/packages/examples/server/tsconfig.json @@ -10,6 +10,7 @@ "@modelcontextprotocol/sdk-core": [ "node_modules/@modelcontextprotocol/sdk-server/node_modules/@modelcontextprotocol/sdk-core/src/index.ts" ], + "@modelcontextprotocol/sdk-examples-shared": ["node_modules/@modelcontextprotocol/sdk-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/pnpm-lock.yaml b/pnpm-lock.yaml index 8a778214e..9531a59f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -444,6 +444,9 @@ importers: packages/examples/server: dependencies: + '@modelcontextprotocol/sdk-examples-shared': + specifier: workspace:^ + version: link:../shared '@modelcontextprotocol/sdk-server': specifier: workspace:^ version: link:../../server From 236e934215d5df0b0308a1fc083afb2109ad1969 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 12:40:39 +0200 Subject: [PATCH 15/22] check:all fix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7911a7490..8d21c999f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepack:all": "pnpm -r prepack", "lint:all": "pnpm -r lint", "lint:fix:all": "pnpm -r lint:fix", - "check:all": "pnpm -r check", + "check:all": "pnpm -r typecheck && pnpm -r lint", "test:all": "pnpm -r test" }, "dependencies": { From f20be12150e230fae05aa26c0391082d99f63382 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 14:05:10 +0200 Subject: [PATCH 16/22] import fixes --- common/eslint-config/eslint.config.mjs | 17 +- common/eslint-config/package.json | 4 +- packages/client/src/client/auth-extensions.ts | 4 +- packages/client/src/client/auth.ts | 17 +- packages/client/src/client/client.ts | 30 +- packages/client/src/client/middleware.ts | 5 +- packages/client/src/client/sse.ts | 7 +- packages/client/src/client/stdio.ts | 8 +- packages/client/src/client/streamableHttp.ts | 10 +- packages/client/src/client/websocket.ts | 3 +- .../client/src/experimental/tasks/client.ts | 25 +- packages/core/src/auth/errors.ts | 2 +- .../core/src/experimental/tasks/interfaces.ts | 2 +- .../experimental/tasks/stores/in-memory.ts | 5 +- packages/core/src/shared/metadataUtils.ts | 2 +- packages/core/src/shared/protocol.ts | 54 +- packages/core/src/shared/responseMessage.ts | 2 +- packages/core/src/shared/stdio.ts | 3 +- packages/core/src/shared/transport.ts | 2 +- packages/core/src/util/inMemory.ts | 5 +- .../core/src/util/zod-json-schema-compat.ts | 3 +- .../client/src/elicitationUrlExample.ts | 24 +- .../client/src/multipleClientsParallel.ts | 10 +- .../client/src/parallelToolCallsClient.ts | 9 +- .../client/src/simpleClientCredentials.ts | 6 +- .../examples/client/src/simpleOAuthClient.ts | 13 +- .../client/src/simpleOAuthClientProvider.ts | 3 +- .../client/src/simpleStreamableHttp.ts | 26 +- .../client/src/simpleTaskInteractiveClient.ts | 10 +- .../examples/client/src/ssePollingClient.ts | 9 +- .../streamableHttpWithSseFallbackClient.ts | 9 +- .../server/src/elicitationFormExample.ts | 5 +- .../server/src/elicitationUrlExample.ts | 23 +- .../examples/server/src/inMemoryEventStore.ts | 3 +- .../server/src/jsonResponseStreamableHttp.ts | 8 +- .../server/src/mcpServerOutputSchema.ts | 3 +- .../examples/server/src/simpleSseServer.ts | 8 +- .../src/simpleStatelessStreamableHttp.ts | 8 +- .../server/src/simpleStreamableHttp.ts | 30 +- .../server/src/simpleTaskInteractive.ts | 24 +- .../sseAndStreamableHttpCompatibleServer.ts | 15 +- .../examples/server/src/ssePollingExample.ts | 8 +- .../src/standaloneSseWithGetStreamableHttp.ts | 8 +- .../server/src/toolWithSampleServer.ts | 3 +- .../shared/src/demoInMemoryOAuthProvider.ts | 23 +- .../test/__fixtures__/serverThatHangs.ts | 3 +- .../test/__fixtures__/testServer.ts | 3 +- .../integration/test/client/client.test.ts | 11 +- packages/integration/test/helpers/http.ts | 2 +- packages/integration/test/helpers/mcp.ts | 3 +- packages/integration/test/helpers/oauth.ts | 2 +- packages/integration/test/helpers/tasks.ts | 2 +- .../integration/test/processCleanup.test.ts | 7 +- packages/integration/test/server.test.ts | 20 +- .../test/server/elicitation.test.ts | 11 +- packages/integration/test/server/mcp.test.ts | 5 +- .../stateManagementStreamableHttp.test.ts | 7 +- .../integration/test/taskLifecycle.test.ts | 12 +- .../integration/test/taskResumability.test.ts | 14 +- packages/integration/test/title.test.ts | 3 +- packages/server/src/experimental/index.js | 13 - .../server/src/experimental/tasks/index.js | 12 - .../src/experimental/tasks/interfaces.js | 6 - .../src/experimental/tasks/interfaces.ts | 16 +- .../src/experimental/tasks/mcp-server.js | 43 - .../src/experimental/tasks/mcp-server.ts | 4 +- .../server/src/experimental/tasks/server.js | 90 -- .../server/src/experimental/tasks/server.ts | 9 +- packages/server/src/index.ts | 2 +- packages/server/src/server/auth/clients.ts | 2 +- .../src/server/auth/handlers/authorize.ts | 9 +- .../src/server/auth/handlers/metadata.ts | 5 +- .../src/server/auth/handlers/register.ts | 18 +- .../server/src/server/auth/handlers/revoke.ts | 17 +- .../server/src/server/auth/handlers/token.ts | 10 +- .../server/auth/middleware/allowedMethods.ts | 4 +- .../src/server/auth/middleware/bearerAuth.ts | 8 +- .../src/server/auth/middleware/clientAuth.ts | 8 +- packages/server/src/server/auth/provider.ts | 7 +- .../server/auth/providers/proxyProvider.ts | 18 +- packages/server/src/server/auth/router.ts | 19 +- packages/server/src/server/completable.ts | 2 +- packages/server/src/server/express.ts | 3 +- .../server/src/server/inMemoryEventStore.ts | 2 +- packages/server/src/server/mcp.ts | 62 +- .../server/middleware/hostHeaderValidation.js | 77 - .../server/middleware/hostHeaderValidation.ts | 2 +- packages/server/src/server/server.ts | 82 +- packages/server/src/server/sse.ts | 7 +- packages/server/src/server/stdio.ts | 7 +- packages/server/src/server/streamableHttp.ts | 11 +- pnpm-lock.yaml | 1305 ++++++++++++++++- 92 files changed, 1796 insertions(+), 687 deletions(-) delete mode 100644 packages/server/src/experimental/index.js delete mode 100644 packages/server/src/experimental/tasks/index.js delete mode 100644 packages/server/src/experimental/tasks/interfaces.js delete mode 100644 packages/server/src/experimental/tasks/mcp-server.js delete mode 100644 packages/server/src/experimental/tasks/server.js delete mode 100644 packages/server/src/server/middleware/hostHeaderValidation.js diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index f2c30824d..b6bf33f50 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -4,6 +4,7 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import eslintConfigPrettier from 'eslint-config-prettier/flat'; import nodePlugin from 'eslint-plugin-n'; +import importPlugin from 'eslint-plugin-import'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -12,6 +13,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.typescript, { languageOptions: { parserOptions: { @@ -25,9 +28,21 @@ export default tseslint.config( plugins: { n: nodePlugin }, + settings: { + 'import/resolver': { + typescript: { + // Let the TS resolver handle NodeNext-style imports like "./foo.js" + // while the actual file is "./foo.ts" + extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], + // Use the tsconfig in each package root (when running ESLint from that package) + project: 'tsconfig.json', + }, + }, + }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - 'n/prefer-node-protocol': 'error' + 'n/prefer-node-protocol': 'error', + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], } }, { diff --git a/common/eslint-config/package.json b/common/eslint-config/package.json index 93de60e6d..05db982e4 100644 --- a/common/eslint-config/package.json +++ b/common/eslint-config/package.json @@ -22,12 +22,14 @@ }, "version": "2.0.0", "devDependencies": { + "@eslint/js": "^9.39.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", "eslint-plugin-n": "^17.23.1", "prettier": "3.6.2", "typescript": "^5.5.4", "typescript-eslint": "^8.48.1", - "@eslint/js": "^9.39.1" + "eslint-import-resolver-typescript": "^4.4.4" } } diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts index e63630834..0d17959d5 100644 --- a/packages/client/src/client/auth-extensions.ts +++ b/packages/client/src/client/auth-extensions.ts @@ -6,8 +6,8 @@ */ import type { CryptoKey, JWK } from 'jose'; -import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-core'; -import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; +import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-core'; +import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** * Helper to produce a private_key_jwt client authentication function. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index b174c780a..fb5e02844 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,6 +1,5 @@ import pkceChallenge from 'pkce-challenge'; -import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk-core'; -import { +import type { OAuthClientMetadata, OAuthClientInformation, OAuthClientInformationMixed, @@ -8,18 +7,19 @@ import { OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata, - OAuthErrorResponseSchema, AuthorizationServerMetadata, - OpenIdProviderDiscoveryMetadataSchema + FetchLike } from '@modelcontextprotocol/sdk-core'; import { + LATEST_PROTOCOL_VERSION, + OAuthErrorResponseSchema, + OpenIdProviderDiscoveryMetadataSchema, OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, - OAuthTokensSchema -} from '@modelcontextprotocol/sdk-core'; -import { checkResourceAllowed, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk-core'; -import { + OAuthTokensSchema, + checkResourceAllowed, + resourceUrlFromServerUrl, InvalidClientError, InvalidClientMetadataError, InvalidGrantError, @@ -28,7 +28,6 @@ import { ServerError, UnauthorizedClientError } from '@modelcontextprotocol/sdk-core'; -import { FetchLike } from '@modelcontextprotocol/sdk-core'; /** * Function type for adding client authentication to token requests. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 1be877849..fcc1603a3 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,7 +1,16 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '@modelcontextprotocol/sdk-core'; -import type { Transport } from '@modelcontextprotocol/sdk-core'; - -import { +import type { + Transport, + ListChangedOptions, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + AnyObjectSchema, + SchemaOutput, + RequestHandlerExtra, + mergeCapabilities, + Protocol, + type ProtocolOptions, + type RequestOptions, type CallToolRequest, CallToolResultSchema, type ClientCapabilities, @@ -43,27 +52,20 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, - ListChangedOptions, ListChangedOptionsBaseSchema, type ListChangedHandlers, type Request, type Notification, - type Result -} from '@modelcontextprotocol/sdk-core'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; -import { - AnyObjectSchema, - SchemaOutput, + type Result, getObjectShape, isZ4Schema, safeParse, type ZodV3Internal, type ZodV4Internal } from '@modelcontextprotocol/sdk-core'; -import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk-core'; + +import { AjvJsonSchemaValidator, assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/sdk-core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/sdk-core'; /** * Elicitation default application helper. Applies defaults to the data based on the schema. diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index 46061ba16..bbb77be47 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,5 +1,6 @@ -import { auth, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; -import { FetchLike } from '@modelcontextprotocol/sdk-core'; +import type { OAuthClientProvider } from './auth.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk-core'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index e03ee1109..fec8bf7cc 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,7 +1,8 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/sdk-core'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; -import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import type { Transport, FetchLike, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import { createFetchWithInit, normalizeHeaders, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; +import type { AuthResult, OAuthClientProvider } from './auth.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; export class SseError extends Error { constructor( diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index f070fe129..371878d14 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -1,10 +1,10 @@ -import { ChildProcess, IOType } from 'node:child_process'; +import type { ChildProcess, IOType } from 'node:child_process'; import spawn from 'cross-spawn'; import process from 'node:process'; -import { Stream, PassThrough } from 'node:stream'; +import type { Stream } from 'node:stream'; +import { PassThrough } from 'node:stream'; +import type { Transport, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; -import { Transport } from '@modelcontextprotocol/sdk-core'; -import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; export type StdioServerParameters = { /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index e67360c71..5a475abf0 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,14 +1,16 @@ -import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '@modelcontextprotocol/sdk-core'; +import type { Transport, FetchLike, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import { + createFetchWithInit, + normalizeHeaders, isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, - JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; -import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import type { AuthResult, OAuthClientProvider } from './auth.js'; +import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; -import { ReadableWritablePair } from 'node:stream/web'; +import type { ReadableWritablePair } from 'node:stream/web'; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index eae23c5da..ccc26a88e 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,5 +1,4 @@ -import { Transport } from '@modelcontextprotocol/sdk-core'; -import { JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; +import type { Transport, JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; const SUBPROTOCOL = 'mcp'; diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 8536f0cf8..fd8a2b4a0 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -6,13 +6,24 @@ */ import type { Client } from '../../client/client.js'; -import type { RequestOptions } from '@modelcontextprotocol/sdk-core'; -import type { ResponseMessage } from '@modelcontextprotocol/sdk-core'; -import type { AnyObjectSchema, SchemaOutput } from '@modelcontextprotocol/sdk-core'; -import type { CallToolRequest, ClientRequest, Notification, Request, Result } from '@modelcontextprotocol/sdk-core'; -import { CallToolResultSchema, type CompatibilityCallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk-core'; - -import type { GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/sdk-core'; +import type { + RequestOptions, + ResponseMessage, + AnyObjectSchema, + SchemaOutput, + CallToolRequest, + ClientRequest, + Notification, + Request, + Result, + CallToolResultSchema, + type CompatibilityCallToolResultSchema, + McpError, + ErrorCode, + GetTaskResult, + ListTasksResult, + CancelTaskResult +} from '@modelcontextprotocol/sdk-core'; /** * Internal interface for accessing Client's private methods. diff --git a/packages/core/src/auth/errors.ts b/packages/core/src/auth/errors.ts index 9d29805fb..d145b6296 100644 --- a/packages/core/src/auth/errors.ts +++ b/packages/core/src/auth/errors.ts @@ -1,4 +1,4 @@ -import { OAuthErrorResponse } from '../shared/auth.js'; +import type { OAuthErrorResponse } from '../shared/auth.js'; /** * Base class for all OAuth errors diff --git a/packages/core/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts index 272f6be61..6fd439fa7 100644 --- a/packages/core/src/experimental/tasks/interfaces.ts +++ b/packages/core/src/experimental/tasks/interfaces.ts @@ -3,7 +3,7 @@ * WARNING: These APIs are experimental and may change without notice. */ -import { +import type { Task, RequestId, Result, diff --git a/packages/core/src/experimental/tasks/stores/in-memory.ts b/packages/core/src/experimental/tasks/stores/in-memory.ts index 8ef54e599..9fcae408c 100644 --- a/packages/core/src/experimental/tasks/stores/in-memory.ts +++ b/packages/core/src/experimental/tasks/stores/in-memory.ts @@ -5,8 +5,9 @@ * @experimental */ -import { Task, RequestId, Result, Request } from '../../../types/types.js'; -import { TaskStore, isTerminal, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; +import type { Task, RequestId, Result, Request } from '../../../types/types.js'; +import type { TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; +import { isTerminal } from '../interfaces.js'; import { randomBytes } from 'node:crypto'; interface StoredTask { diff --git a/packages/core/src/shared/metadataUtils.ts b/packages/core/src/shared/metadataUtils.ts index 1a05c2a8f..6cede430b 100644 --- a/packages/core/src/shared/metadataUtils.ts +++ b/packages/core/src/shared/metadataUtils.ts @@ -1,4 +1,4 @@ -import { BaseMetadata } from '../types/types.js'; +import type { BaseMetadata } from '../types/types.js'; /** * Utilities for working with BaseMetadata objects. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index b81ad4828..37136a48d 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,32 +1,15 @@ -import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../util/zod-compat.js'; -import { - CancelledNotificationSchema, +import type { AnySchema, AnyObjectSchema, SchemaOutput } from '../util/zod-compat.js'; +import { safeParse } from '../util/zod-compat.js'; +import type { ClientCapabilities, - CreateTaskResultSchema, - ErrorCode, GetTaskRequest, - GetTaskRequestSchema, - GetTaskResultSchema, GetTaskPayloadRequest, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - ListTasksResultSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, - isJSONRPCErrorResponse, - isJSONRPCRequest, - isJSONRPCResultResponse, - isJSONRPCNotification, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, - McpError, - PingRequestSchema, Progress, ProgressNotification, - ProgressNotificationSchema, - RELATED_TASK_META_KEY, RequestId, Result, ServerCapabilities, @@ -39,17 +22,38 @@ import { CancelledNotification, Task, TaskStatusNotification, - TaskStatusNotificationSchema, Request, Notification, JSONRPCResultResponse, + AuthInfo +} from '../types/types.js'; +import { + CancelledNotificationSchema, + CreateTaskResultSchema, + ErrorCode, + GetTaskRequestSchema, + GetTaskResultSchema, + GetTaskPayloadRequestSchema, + ListTasksRequestSchema, + ListTasksResultSchema, + CancelTaskRequestSchema, + CancelTaskResultSchema, + isJSONRPCErrorResponse, + isJSONRPCRequest, + isJSONRPCResultResponse, + isJSONRPCNotification, + McpError, + PingRequestSchema, + ProgressNotificationSchema, + RELATED_TASK_META_KEY, + TaskStatusNotificationSchema, isTaskAugmentedRequestParams } from '../types/types.js'; -import { Transport, TransportSendOptions } from './transport.js'; -import { AuthInfo } from '../types/types.js'; -import { isTerminal, TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; +import type { Transport, TransportSendOptions } from './transport.js'; +import type { TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; +import { isTerminal } from '../experimental/tasks/interfaces.js'; import { getMethodLiteral, parseWithCompat } from '../util/zod-json-schema-compat.js'; -import { ResponseMessage } from './responseMessage.js'; +import type { ResponseMessage } from './responseMessage.js'; /** * Callback for progress notifications. diff --git a/packages/core/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts index 1acd66711..9bed02786 100644 --- a/packages/core/src/shared/responseMessage.ts +++ b/packages/core/src/shared/responseMessage.ts @@ -1,4 +1,4 @@ -import { Result, Task, McpError } from '../types/types.js'; +import type { Result, Task, McpError } from '../types/types.js'; /** * Base message type diff --git a/packages/core/src/shared/stdio.ts b/packages/core/src/shared/stdio.ts index 76e3940fa..49c658b96 100644 --- a/packages/core/src/shared/stdio.ts +++ b/packages/core/src/shared/stdio.ts @@ -1,4 +1,5 @@ -import { JSONRPCMessage, JSONRPCMessageSchema } from '../types/types.js'; +import type { JSONRPCMessage } from '../types/types.js'; +import { JSONRPCMessageSchema } from '../types/types.js'; /** * Buffers a continuous stdio stream into discrete JSON-RPC messages. diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index e20cb6f09..87608f124 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/types.js'; +import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/types.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts index ca64f10fa..32af248c7 100644 --- a/packages/core/src/util/inMemory.ts +++ b/packages/core/src/util/inMemory.ts @@ -1,6 +1,5 @@ -import { Transport } from '../shared/transport.js'; -import { JSONRPCMessage, RequestId } from '../types/types.js'; -import { AuthInfo } from '../types/types.js'; +import type { Transport } from '../shared/transport.js'; +import type { JSONRPCMessage, RequestId, AuthInfo } from '../types/types.js'; interface QueuedMessage { message: JSONRPCMessage; diff --git a/packages/core/src/util/zod-json-schema-compat.ts b/packages/core/src/util/zod-json-schema-compat.ts index cde66b177..8dade0dfb 100644 --- a/packages/core/src/util/zod-json-schema-compat.ts +++ b/packages/core/src/util/zod-json-schema-compat.ts @@ -9,7 +9,8 @@ import type * as z4c from 'zod/v4/core'; import * as z4mini from 'zod/v4-mini'; -import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; +import type { AnySchema, AnyObjectSchema } from './zod-compat.js'; +import { getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; type JsonSchema = Record; diff --git a/packages/examples/client/src/elicitationUrlExample.ts b/packages/examples/client/src/elicitationUrlExample.ts index 563a435e4..e9b53570b 100644 --- a/packages/examples/client/src/elicitationUrlExample.ts +++ b/packages/examples/client/src/elicitationUrlExample.ts @@ -5,29 +5,31 @@ // URL elicitation allows servers to prompt the end-user to open a URL in their browser // to collect sensitive information. -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; -import { +import type { ListToolsRequest, - ListToolsResultSchema, CallToolRequest, - CallToolResultSchema, - ElicitRequestSchema, ElicitRequest, ElicitResult, ResourceLink, ElicitRequestURLParams, + OAuthClientMetadata +} from '@modelcontextprotocol/sdk-client'; +import { + Client, + StreamableHTTPClientTransport, + ListToolsResultSchema, + CallToolResultSchema, + ElicitRequestSchema, McpError, ErrorCode, UrlElicitationRequiredError, - ElicitationCompleteNotificationSchema + ElicitationCompleteNotificationSchema, + getDisplayName, + UnauthorizedError } from '@modelcontextprotocol/sdk-client'; -import { getDisplayName } from '@modelcontextprotocol/sdk-client'; -import { OAuthClientMetadata } from '@modelcontextprotocol/sdk-client'; +import { createInterface } from 'node:readline'; import { exec } from 'node:child_process'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -import { UnauthorizedError } from '@modelcontextprotocol/sdk-client'; import { createServer } from 'node:http'; // Set up OAuth (required for this example) diff --git a/packages/examples/client/src/multipleClientsParallel.ts b/packages/examples/client/src/multipleClientsParallel.ts index d4a0ec1f7..d6b976008 100644 --- a/packages/examples/client/src/multipleClientsParallel.ts +++ b/packages/examples/client/src/multipleClientsParallel.ts @@ -1,6 +1,10 @@ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema, CallToolResult } from '@modelcontextprotocol/sdk-client'; +import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/sdk-client'; +import { + Client, + StreamableHTTPClientTransport, + CallToolResultSchema, + LoggingMessageNotificationSchema +} from '@modelcontextprotocol/sdk-client'; /** * Multiple Clients MCP Example diff --git a/packages/examples/client/src/parallelToolCallsClient.ts b/packages/examples/client/src/parallelToolCallsClient.ts index 1ce6bf2cf..5b4feb49d 100644 --- a/packages/examples/client/src/parallelToolCallsClient.ts +++ b/packages/examples/client/src/parallelToolCallsClient.ts @@ -1,11 +1,10 @@ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import type { ListToolsRequest, CallToolResult } from '@modelcontextprotocol/sdk-client'; import { - ListToolsRequest, + Client, + StreamableHTTPClientTransport, ListToolsResultSchema, CallToolResultSchema, - LoggingMessageNotificationSchema, - CallToolResult + LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-client'; /** diff --git a/packages/examples/client/src/simpleClientCredentials.ts b/packages/examples/client/src/simpleClientCredentials.ts index f22801a75..a1f8db1a2 100644 --- a/packages/examples/client/src/simpleClientCredentials.ts +++ b/packages/examples/client/src/simpleClientCredentials.ts @@ -18,10 +18,8 @@ * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) */ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk-client'; -import { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; +import { Client, StreamableHTTPClientTransport, ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk-client'; const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; diff --git a/packages/examples/client/src/simpleOAuthClient.ts b/packages/examples/client/src/simpleOAuthClient.ts index abcea852b..2bd418aef 100644 --- a/packages/examples/client/src/simpleOAuthClient.ts +++ b/packages/examples/client/src/simpleOAuthClient.ts @@ -4,11 +4,14 @@ import { createServer } from 'node:http'; import { createInterface } from 'node:readline'; import { URL } from 'node:url'; import { exec } from 'node:child_process'; -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { OAuthClientMetadata } from '@modelcontextprotocol/sdk-client'; -import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk-client'; -import { UnauthorizedError } from '@modelcontextprotocol/sdk-client'; +import type { OAuthClientMetadata, CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk-client'; +import { + Client, + StreamableHTTPClientTransport, + CallToolResultSchema, + ListToolsResultSchema, + UnauthorizedError +} from '@modelcontextprotocol/sdk-client'; import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration diff --git a/packages/examples/client/src/simpleOAuthClientProvider.ts b/packages/examples/client/src/simpleOAuthClientProvider.ts index 4304b09e9..6917729f6 100644 --- a/packages/examples/client/src/simpleOAuthClientProvider.ts +++ b/packages/examples/client/src/simpleOAuthClientProvider.ts @@ -1,5 +1,4 @@ -import { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; -import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-client'; +import type { OAuthClientProvider, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-client'; /** * In-memory OAuth client provider for demonstration purposes diff --git a/packages/examples/client/src/simpleStreamableHttp.ts b/packages/examples/client/src/simpleStreamableHttp.ts index ee8eccc05..62c67d21f 100644 --- a/packages/examples/client/src/simpleStreamableHttp.ts +++ b/packages/examples/client/src/simpleStreamableHttp.ts @@ -1,28 +1,30 @@ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; -import { +import type { ListToolsRequest, - ListToolsResultSchema, CallToolRequest, - CallToolResultSchema, ListPromptsRequest, - ListPromptsResultSchema, GetPromptRequest, - GetPromptResultSchema, ListResourcesRequest, + ResourceLink, + ReadResourceRequest +} from '@modelcontextprotocol/sdk-client'; +import { + Client, + StreamableHTTPClientTransport, + ListToolsResultSchema, + CallToolResultSchema, + ListPromptsResultSchema, + GetPromptResultSchema, ListResourcesResultSchema, LoggingMessageNotificationSchema, ResourceListChangedNotificationSchema, ElicitRequestSchema, - ResourceLink, - ReadResourceRequest, ReadResourceResultSchema, RELATED_TASK_META_KEY, ErrorCode, - McpError + McpError, + getDisplayName } from '@modelcontextprotocol/sdk-client'; -import { getDisplayName } from '@modelcontextprotocol/sdk-client'; +import { createInterface } from 'node:readline'; import { Ajv } from 'ajv'; // Create readline interface for user input diff --git a/packages/examples/client/src/simpleTaskInteractiveClient.ts b/packages/examples/client/src/simpleTaskInteractiveClient.ts index 56a36f178..09f2c8b19 100644 --- a/packages/examples/client/src/simpleTaskInteractiveClient.ts +++ b/packages/examples/client/src/simpleTaskInteractiveClient.ts @@ -7,19 +7,17 @@ * - Using task-based tool execution with streaming */ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; +import type { TextContent, CreateMessageRequest, CreateMessageResult } from '@modelcontextprotocol/sdk-client'; import { + Client, + StreamableHTTPClientTransport, CallToolResultSchema, - TextContent, ElicitRequestSchema, CreateMessageRequestSchema, - CreateMessageRequest, - CreateMessageResult, ErrorCode, McpError } from '@modelcontextprotocol/sdk-client'; +import { createInterface } from 'node:readline'; // Create readline interface for user input const readline = createInterface({ diff --git a/packages/examples/client/src/ssePollingClient.ts b/packages/examples/client/src/ssePollingClient.ts index a9c3cfdfb..fa16783a8 100644 --- a/packages/examples/client/src/ssePollingClient.ts +++ b/packages/examples/client/src/ssePollingClient.ts @@ -12,9 +12,12 @@ * Run with: npx tsx src/examples/client/ssePollingClient.ts * Requires: ssePollingExample.ts server running on port 3001 */ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { CallToolResultSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-client'; +import { + Client, + StreamableHTTPClientTransport, + CallToolResultSchema, + LoggingMessageNotificationSchema +} from '@modelcontextprotocol/sdk-client'; const SERVER_URL = 'http://localhost:3001/mcp'; diff --git a/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts b/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts index c2328f911..31a45e856 100644 --- a/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ b/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts @@ -1,10 +1,9 @@ -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk-client'; +import type { ListToolsRequest, CallToolRequest } from '@modelcontextprotocol/sdk-client'; import { - ListToolsRequest, + Client, + StreamableHTTPClientTransport, + SSEClientTransport, ListToolsResultSchema, - CallToolRequest, CallToolResultSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-client'; diff --git a/packages/examples/server/src/elicitationFormExample.ts b/packages/examples/server/src/elicitationFormExample.ts index fc387a305..51fdd27ac 100644 --- a/packages/examples/server/src/elicitationFormExample.ts +++ b/packages/examples/server/src/elicitationFormExample.ts @@ -9,10 +9,7 @@ import { randomUUID } from 'node:crypto'; import { type Request, type Response } from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed diff --git a/packages/examples/server/src/elicitationUrlExample.ts b/packages/examples/server/src/elicitationUrlExample.ts index 8a832e0d5..2ecabc685 100644 --- a/packages/examples/server/src/elicitationUrlExample.ts +++ b/packages/examples/server/src/elicitationUrlExample.ts @@ -7,25 +7,24 @@ // 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 express, { Request, Response } from 'express'; +import type { Request, Response } from 'express'; +import express from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk-server'; -import { requireBearerAuth } from '@modelcontextprotocol/sdk-server'; +import type { CallToolResult, ElicitRequestURLParams, ElicitResult, OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { - CallToolResult, + McpServer, + createMcpExpressApp, + StreamableHTTPServerTransport, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth, UrlElicitationRequiredError, - ElicitRequestURLParams, - ElicitResult, - isInitializeRequest + isInitializeRequest, + checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; -import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; -import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; import cors from 'cors'; diff --git a/packages/examples/server/src/inMemoryEventStore.ts b/packages/examples/server/src/inMemoryEventStore.ts index 9612cfee6..4e93e1a8f 100644 --- a/packages/examples/server/src/inMemoryEventStore.ts +++ b/packages/examples/server/src/inMemoryEventStore.ts @@ -1,5 +1,4 @@ -import { JSONRPCMessage } from '@modelcontextprotocol/sdk-server'; -import type { EventStore } from '@modelcontextprotocol/sdk-server'; +import type { JSONRPCMessage, EventStore } from '@modelcontextprotocol/sdk-server'; /** * Simple in-memory implementation of the EventStore interface for resumability diff --git a/packages/examples/server/src/jsonResponseStreamableHttp.ts b/packages/examples/server/src/jsonResponseStreamableHttp.ts index 598362693..c33308729 100644 --- a/packages/examples/server/src/jsonResponseStreamableHttp.ts +++ b/packages/examples/server/src/jsonResponseStreamableHttp.ts @@ -1,10 +1,8 @@ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create an MCP server with implementation details const getServer = () => { diff --git a/packages/examples/server/src/mcpServerOutputSchema.ts b/packages/examples/server/src/mcpServerOutputSchema.ts index e9dae7e1a..076a22c4f 100644 --- a/packages/examples/server/src/mcpServerOutputSchema.ts +++ b/packages/examples/server/src/mcpServerOutputSchema.ts @@ -4,8 +4,7 @@ * This demonstrates how to easily create tools with structured output */ -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; const server = new McpServer({ diff --git a/packages/examples/server/src/simpleSseServer.ts b/packages/examples/server/src/simpleSseServer.ts index 37604640b..7e41c1f71 100644 --- a/packages/examples/server/src/simpleSseServer.ts +++ b/packages/examples/server/src/simpleSseServer.ts @@ -1,9 +1,7 @@ -import { Request, Response } from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; +import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import { McpServer, SSEServerTransport, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; /** * This example server demonstrates the deprecated HTTP+SSE transport diff --git a/packages/examples/server/src/simpleStatelessStreamableHttp.ts b/packages/examples/server/src/simpleStatelessStreamableHttp.ts index 998520dae..34668b27c 100644 --- a/packages/examples/server/src/simpleStatelessStreamableHttp.ts +++ b/packages/examples/server/src/simpleStatelessStreamableHttp.ts @@ -1,9 +1,7 @@ -import { Request, Response } from 'express'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; +import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StreamableHTTPServerTransport, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; const getServer = () => { // Create an MCP server with implementation details diff --git a/packages/examples/server/src/simpleStreamableHttp.ts b/packages/examples/server/src/simpleStreamableHttp.ts index 83dc3568f..fc9fd4702 100644 --- a/packages/examples/server/src/simpleStreamableHttp.ts +++ b/packages/examples/server/src/simpleStreamableHttp.ts @@ -1,25 +1,29 @@ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import * as z from 'zod/v4'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk-server'; -import { requireBearerAuth } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; -import { +import type { CallToolResult, - ElicitResultSchema, GetPromptResult, - isInitializeRequest, PrimitiveSchemaDefinition, ReadResourceResult, - ResourceLink + ResourceLink, + OAuthMetadata +} from '@modelcontextprotocol/sdk-server'; +import { + McpServer, + StreamableHTTPServerTransport, + getOAuthProtectedResourceMetadataUrl, + mcpAuthMetadataRouter, + requireBearerAuth, + createMcpExpressApp, + ElicitResultSchema, + isInitializeRequest, + InMemoryTaskStore, + InMemoryTaskMessageQueue, + checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; -import { OAuthMetadata } from '@modelcontextprotocol/sdk-server'; -import { checkResourceAllowed } from '@modelcontextprotocol/sdk-server'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); diff --git a/packages/examples/server/src/simpleTaskInteractive.ts b/packages/examples/server/src/simpleTaskInteractive.ts index ac660aa1a..b60349324 100644 --- a/packages/examples/server/src/simpleTaskInteractive.ts +++ b/packages/examples/server/src/simpleTaskInteractive.ts @@ -9,18 +9,14 @@ * creates a task, and the result is fetched via tasks/result endpoint. */ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { Server } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { +import type { CallToolResult, CreateTaskResult, GetTaskResult, Tool, TextContent, - RELATED_TASK_META_KEY, Task, Result, RequestId, @@ -31,14 +27,24 @@ import { ElicitResult, CreateMessageResult, PrimitiveSchemaDefinition, + GetTaskPayloadResult, + TaskMessageQueue, + QueuedMessage, + QueuedRequest, + CreateTaskOptions +} from '@modelcontextprotocol/sdk-server'; +import { + Server, + createMcpExpressApp, + StreamableHTTPServerTransport, + RELATED_TASK_META_KEY, ListToolsRequestSchema, CallToolRequestSchema, GetTaskRequestSchema, GetTaskPayloadRequestSchema, - GetTaskPayloadResult + isTerminal, + InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; -import { TaskMessageQueue, QueuedMessage, QueuedRequest, isTerminal, CreateTaskOptions } from '@modelcontextprotocol/sdk-server'; -import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; // ============================================================================ // Resolver - Promise-like for passing results between async operations diff --git a/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts index febfa5a71..4dc4b197f 100644 --- a/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts +++ b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts @@ -1,12 +1,15 @@ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import { + McpServer, + StreamableHTTPServerTransport, + SSEServerTransport, + isInitializeRequest, + createMcpExpressApp +} from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; -import { CallToolResult, isInitializeRequest } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; /** * This example server demonstrates backwards compatibility with both: diff --git a/packages/examples/server/src/ssePollingExample.ts b/packages/examples/server/src/ssePollingExample.ts index bb0d63068..bf10b78dd 100644 --- a/packages/examples/server/src/ssePollingExample.ts +++ b/packages/examples/server/src/ssePollingExample.ts @@ -12,12 +12,10 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; +import { McpServer, createMcpExpressApp, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { InMemoryEventStore } from './inMemoryEventStore.js'; import cors from 'cors'; diff --git a/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts index 9fad4281d..806ab5650 100644 --- a/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts +++ b/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts @@ -1,9 +1,7 @@ -import { Request, Response } from 'express'; +import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { isInitializeRequest, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import type { ReadResourceResult } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create an MCP server with implementation details const server = new McpServer({ diff --git a/packages/examples/server/src/toolWithSampleServer.ts b/packages/examples/server/src/toolWithSampleServer.ts index e1ab1acab..4b592f4b3 100644 --- a/packages/examples/server/src/toolWithSampleServer.ts +++ b/packages/examples/server/src/toolWithSampleServer.ts @@ -1,7 +1,6 @@ // Run with: npx tsx src/examples/server/toolWithSampleServer.ts -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; import * as z from 'zod/v4'; const mcpServer = new McpServer({ diff --git a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts index 507443d58..aef51ecf0 100644 --- a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts +++ b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts @@ -1,12 +1,19 @@ import { randomUUID } from 'node:crypto'; -import { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk-server'; -import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk-server'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-server'; -import express, { Request, Response } from 'express'; -import { AuthInfo } from '@modelcontextprotocol/sdk-server'; -import { createOAuthMetadata, mcpAuthRouter } from '@modelcontextprotocol/sdk-server'; -import { resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk-server'; -import { InvalidRequestError } from '@modelcontextprotocol/sdk-server'; +import type { + AuthorizationParams, + OAuthServerProvider, + OAuthRegisteredClientsStore, + OAuthClientInformationFull, + OAuthMetadata, + OAuthTokens, + AuthInfo, + createOAuthMetadata, + mcpAuthRouter, + resourceUrlFromServerUrl, + InvalidRequestError +} from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; +import express from 'express'; export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { private clients = new Map(); diff --git a/packages/integration/test/__fixtures__/serverThatHangs.ts b/packages/integration/test/__fixtures__/serverThatHangs.ts index 8196d9c83..db3ebce53 100644 --- a/packages/integration/test/__fixtures__/serverThatHangs.ts +++ b/packages/integration/test/__fixtures__/serverThatHangs.ts @@ -1,7 +1,6 @@ import { setInterval } from 'node:timers'; import process from 'node:process'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; const transport = new StdioServerTransport(); diff --git a/packages/integration/test/__fixtures__/testServer.ts b/packages/integration/test/__fixtures__/testServer.ts index 5c633ecf5..6d3164744 100644 --- a/packages/integration/test/__fixtures__/testServer.ts +++ b/packages/integration/test/__fixtures__/testServer.ts @@ -1,5 +1,4 @@ -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; const transport = new StdioServerTransport(); diff --git a/packages/integration/test/client/client.test.ts b/packages/integration/test/client/client.test.ts index 08bbc921c..6b872c8d5 100644 --- a/packages/integration/test/client/client.test.ts +++ b/packages/integration/test/client/client.test.ts @@ -2,6 +2,7 @@ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/sdk-client'; +import type { Tool, Prompt, Resource, Transport } from '@modelcontextprotocol/sdk-core'; import { RequestSchema, NotificationSchema, @@ -22,15 +23,9 @@ import { ErrorCode, McpError, CreateTaskResultSchema, - Tool, - Prompt, - Resource + InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { Transport } from '@modelcontextprotocol/sdk-core'; -import { Server } from '@modelcontextprotocol/sdk-server'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; +import { Server, McpServer, InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; diff --git a/packages/integration/test/helpers/http.ts b/packages/integration/test/helpers/http.ts index 291cc37fa..c4774be89 100644 --- a/packages/integration/test/helpers/http.ts +++ b/packages/integration/test/helpers/http.ts @@ -1,7 +1,7 @@ import type http from 'node:http'; import { type Server } from 'node:http'; import type { Response } from 'express'; -import { AddressInfo } from 'node:net'; +import type { AddressInfo } from 'node:net'; import { vi } from 'vitest'; /** diff --git a/packages/integration/test/helpers/mcp.ts b/packages/integration/test/helpers/mcp.ts index f69d9ee92..6795cb65a 100644 --- a/packages/integration/test/helpers/mcp.ts +++ b/packages/integration/test/helpers/mcp.ts @@ -1,7 +1,6 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; import { Client } from '@modelcontextprotocol/sdk-client'; -import { Server } from '@modelcontextprotocol/sdk-server'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import { Server, InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/sdk-server'; export interface InMemoryTaskEnvironment { diff --git a/packages/integration/test/helpers/oauth.ts b/packages/integration/test/helpers/oauth.ts index 49d141c7c..afb834c16 100644 --- a/packages/integration/test/helpers/oauth.ts +++ b/packages/integration/test/helpers/oauth.ts @@ -1,4 +1,4 @@ -import type { FetchLike } from '../../../core/src/shared/transport.js'; +import type { FetchLike } from '@modelcontextprotocol/sdk-core'; export interface MockOAuthFetchOptions { resourceServerUrl: string; diff --git a/packages/integration/test/helpers/tasks.ts b/packages/integration/test/helpers/tasks.ts index 60aa4cd7c..6b8db0c1b 100644 --- a/packages/integration/test/helpers/tasks.ts +++ b/packages/integration/test/helpers/tasks.ts @@ -1,4 +1,4 @@ -import type { Task } from '../../../core/src/types/types.js'; +import type { Task } from '@modelcontextprotocol/sdk-core'; /** * Polls the provided getTask function until the task reaches the desired status or times out. diff --git a/packages/integration/test/processCleanup.test.ts b/packages/integration/test/processCleanup.test.ts index fe4adf149..484767b77 100644 --- a/packages/integration/test/processCleanup.test.ts +++ b/packages/integration/test/processCleanup.test.ts @@ -1,10 +1,7 @@ import path from 'node:path'; import { Readable, Writable } from 'node:stream'; -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk-client'; -import { Server } from '@modelcontextprotocol/sdk-server'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk-server'; -import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; +import { Client, StdioClientTransport } from '@modelcontextprotocol/sdk-client'; +import { Server, StdioServerTransport, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; // Use the local fixtures directory alongside this test file const FIXTURES_DIR = path.resolve(__dirname, './__fixtures__'); diff --git a/packages/integration/test/server.test.ts b/packages/integration/test/server.test.ts index 543c82739..0b36ba7c6 100644 --- a/packages/integration/test/server.test.ts +++ b/packages/integration/test/server.test.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import supertest from 'supertest'; import { Client } from '@modelcontextprotocol/sdk-client'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import type { Transport } from '@modelcontextprotocol/sdk-core'; -import { +import { InMemoryTransport, CallToolRequestSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk-core'; +import type { + Transport, CreateMessageRequestSchema, CreateMessageResultSchema, ElicitRequestSchema, @@ -21,17 +21,15 @@ import { ResultSchema, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, - CreateTaskResultSchema + CreateTaskResultSchema, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + AnyObjectSchema } from '@modelcontextprotocol/sdk-core'; -import { Server } from '@modelcontextprotocol/sdk-server'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; -import { CallToolRequestSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk-core'; -import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; -import type { AnyObjectSchema } from '@modelcontextprotocol/sdk-core'; +import { Server, McpServer, InMemoryTaskStore, InMemoryTaskMessageQueue, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; -import { createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; describe('Zod v3', () => { /* diff --git a/packages/integration/test/server/elicitation.test.ts b/packages/integration/test/server/elicitation.test.ts index ea783ebe2..33e5ce5b0 100644 --- a/packages/integration/test/server/elicitation.test.ts +++ b/packages/integration/test/server/elicitation.test.ts @@ -8,10 +8,13 @@ */ import { Client } from '@modelcontextprotocol/sdk-client'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { ElicitRequestFormParams, ElicitRequestSchema } from '@modelcontextprotocol/sdk-core'; -import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; -import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk-core'; +import type { ElicitRequestFormParams } from '@modelcontextprotocol/sdk-core'; +import { + InMemoryTransport, + ElicitRequestSchema, + AjvJsonSchemaValidator, + CfWorkerJsonSchemaValidator +} from '@modelcontextprotocol/sdk-core'; import { Server } from '@modelcontextprotocol/sdk-server'; const ajvProvider = new AjvJsonSchemaValidator(); diff --git a/packages/integration/test/server/mcp.test.ts b/packages/integration/test/server/mcp.test.ts index 70696f188..9dfad8689 100644 --- a/packages/integration/test/server/mcp.test.ts +++ b/packages/integration/test/server/mcp.test.ts @@ -1,7 +1,5 @@ import { Client } from '@modelcontextprotocol/sdk-client'; -import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { getDisplayName } from '@modelcontextprotocol/sdk-core'; -import { UriTemplate } from '@modelcontextprotocol/sdk-core'; +import { InMemoryTransport, getDisplayName, UriTemplate, InMemoryTaskStore } from '@modelcontextprotocol/sdk-core'; import { CallToolResultSchema, type CallToolResult, @@ -21,7 +19,6 @@ import { } from '@modelcontextprotocol/sdk-core'; import { completable } from '../../../server/src/server/completable.js'; import { McpServer, ResourceTemplate } from '../../../server/src/server/mcp.js'; -import { InMemoryTaskStore } from '@modelcontextprotocol/sdk-core'; import { zodTestMatrix, type ZodMatrixEntry } from '../../../server/test/server/__fixtures__/zodTestMatrix.js'; function createLatch() { diff --git a/packages/integration/test/stateManagementStreamableHttp.test.ts b/packages/integration/test/stateManagementStreamableHttp.test.ts index f3d7eb0cf..877685550 100644 --- a/packages/integration/test/stateManagementStreamableHttp.test.ts +++ b/packages/integration/test/stateManagementStreamableHttp.test.ts @@ -1,10 +1,9 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { + McpServer, + StreamableHTTPServerTransport, CallToolResultSchema, ListToolsResultSchema, ListResourcesResultSchema, diff --git a/packages/integration/test/taskLifecycle.test.ts b/packages/integration/test/taskLifecycle.test.ts index cdd09a226..f00f4cd9e 100644 --- a/packages/integration/test/taskLifecycle.test.ts +++ b/packages/integration/test/taskLifecycle.test.ts @@ -1,10 +1,9 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { + McpServer, + StreamableHTTPServerTransport, CallToolResultSchema, CreateTaskResultSchema, ElicitRequestSchema, @@ -12,10 +11,11 @@ import { ErrorCode, McpError, RELATED_TASK_META_KEY, - TaskSchema + TaskSchema, + InMemoryTaskStore, + InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import { z } from 'zod'; -import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; import type { TaskRequestOptions } from '@modelcontextprotocol/sdk-server'; import { listenOnRandomPort } from './helpers/http.js'; import { waitForTaskStatus } from './helpers/tasks.js'; diff --git a/packages/integration/test/taskResumability.test.ts b/packages/integration/test/taskResumability.test.ts index b9f2bccb6..7ba03a026 100644 --- a/packages/integration/test/taskResumability.test.ts +++ b/packages/integration/test/taskResumability.test.ts @@ -1,11 +1,13 @@ import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; -import { Client } from '@modelcontextprotocol/sdk-client'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { McpServer } from '@modelcontextprotocol/sdk-server'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { CallToolResultSchema, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from '@modelcontextprotocol/sdk-server'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import { + McpServer, + StreamableHTTPServerTransport, + CallToolResultSchema, + LoggingMessageNotificationSchema, + InMemoryEventStore +} from '@modelcontextprotocol/sdk-server'; import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; import { listenOnRandomPort } from './helpers/http.js'; diff --git a/packages/integration/test/title.test.ts b/packages/integration/test/title.test.ts index de389eed2..f44d8c7ed 100644 --- a/packages/integration/test/title.test.ts +++ b/packages/integration/test/title.test.ts @@ -1,7 +1,6 @@ -import { Server } from '@modelcontextprotocol/sdk-server'; +import { Server, McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk-server'; import { Client } from '@modelcontextprotocol/sdk-client'; import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk-server'; import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { diff --git a/packages/server/src/experimental/index.js b/packages/server/src/experimental/index.js deleted file mode 100644 index de0a7bc9b..000000000 --- a/packages/server/src/experimental/index.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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'; -//# sourceMappingURL=index.js.map diff --git a/packages/server/src/experimental/tasks/index.js b/packages/server/src/experimental/tasks/index.js deleted file mode 100644 index 6d480deaf..000000000 --- a/packages/server/src/experimental/tasks/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Experimental task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ -// SDK implementation interfaces -export * from './interfaces.js'; -// Wrapper classes -export * from './server.js'; -export * from './mcp-server.js'; -//# sourceMappingURL=index.js.map diff --git a/packages/server/src/experimental/tasks/interfaces.js b/packages/server/src/experimental/tasks/interfaces.js deleted file mode 100644 index b57b748c6..000000000 --- a/packages/server/src/experimental/tasks/interfaces.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Experimental task interfaces for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - */ -export {}; -//# sourceMappingURL=interfaces.js.map diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index 9b4ff7595..21eba4296 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -3,11 +3,17 @@ * WARNING: These APIs are experimental and may change without notice. */ -import { Result, CallToolResult, GetTaskResult } from '@modelcontextprotocol/sdk-core'; -import { CreateTaskResult } from '@modelcontextprotocol/sdk-core'; -import type { CreateTaskRequestHandlerExtra, TaskRequestHandlerExtra } from '@modelcontextprotocol/sdk-core'; -import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk-core'; -import { BaseToolCallback } from '../../server/mcp.js'; +import type { + Result, + CallToolResult, + GetTaskResult, + CreateTaskResult, + CreateTaskRequestHandlerExtra, + TaskRequestHandlerExtra, + ZodRawShapeCompat, + AnySchema +} from '@modelcontextprotocol/sdk-core'; +import type { BaseToolCallback } from '../../server/mcp.js'; // ============================================================================ // Task Handler Types (for registerToolTask) diff --git a/packages/server/src/experimental/tasks/mcp-server.js b/packages/server/src/experimental/tasks/mcp-server.js deleted file mode 100644 index 645878ed2..000000000 --- a/packages/server/src/experimental/tasks/mcp-server.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Experimental McpServer task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ -/** - * Experimental task features for McpServer. - * - * Access via `server.experimental.tasks`: - * ```typescript - * server.experimental.tasks.registerToolTask('long-running', config, handler); - * ``` - * - * @experimental - */ -export class ExperimentalMcpServerTasks { - _mcpServer; - constructor(_mcpServer) { - this._mcpServer = _mcpServer; - } - registerToolTask(name, config, handler) { - // Validate that taskSupport is not 'forbidden' for task-based tools - const execution = { 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; - return mcpServerInternal._createRegisteredTool( - name, - config.title, - config.description, - config.inputSchema, - config.outputSchema, - config.annotations, - execution, - config._meta, - handler - ); - } -} -//# sourceMappingURL=mcp-server.js.map diff --git a/packages/server/src/experimental/tasks/mcp-server.ts b/packages/server/src/experimental/tasks/mcp-server.ts index 43e9252c7..7b6caf794 100644 --- a/packages/server/src/experimental/tasks/mcp-server.ts +++ b/packages/server/src/experimental/tasks/mcp-server.ts @@ -6,10 +6,8 @@ */ import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; -import type { ZodRawShapeCompat, AnySchema } from '@modelcontextprotocol/sdk-core'; -import type { ToolAnnotations, ToolExecution } from '@modelcontextprotocol/sdk-core'; +import type { ZodRawShapeCompat, AnySchema, ToolAnnotations, ToolExecution, TaskToolExecution } from '@modelcontextprotocol/sdk-core'; import type { ToolTaskHandler } from './interfaces.js'; -import type { TaskToolExecution } from '@modelcontextprotocol/sdk-core'; /** * Internal interface for accessing McpServer's private _createRegisteredTool method. diff --git a/packages/server/src/experimental/tasks/server.js b/packages/server/src/experimental/tasks/server.js deleted file mode 100644 index 38f956338..000000000 --- a/packages/server/src/experimental/tasks/server.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Experimental server task features for MCP SDK. - * WARNING: These APIs are experimental and may change without notice. - * - * @experimental - */ -/** - * Experimental task features for low-level MCP servers. - * - * Access via `server.experimental.tasks`: - * ```typescript - * const stream = server.experimental.tasks.requestStream(request, schema, options); - * ``` - * - * For high-level server usage with task-based tools, use `McpServer.experimental.tasks` instead. - * - * @experimental - */ -export class ExperimentalServerTasks { - _server; - constructor(_server) { - this._server = _server; - } - /** - * 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 - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options (timeout, signal, task creation params, etc.) - * @returns AsyncGenerator that yields ResponseMessage objects - * - * @experimental - */ - requestStream(request, resultSchema, options) { - return this._server.requestStream(request, resultSchema, options); - } - /** - * 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, options) { - return this._server.getTask({ taskId }, options); - } - /** - * Retrieves the result of a completed task. - * - * @param taskId - The task identifier - * @param resultSchema - Zod schema for validating the result - * @param options - Optional request options - * @returns The task result - * - * @experimental - */ - async getTaskResult(taskId, resultSchema, options) { - return this._server.getTaskResult({ taskId }, resultSchema, 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, options) { - return this._server.listTasks(cursor ? { cursor } : undefined, options); - } - /** - * Cancels a running task. - * - * @param taskId - The task identifier - * @param options - Optional request options - * - * @experimental - */ - async cancelTask(taskId, options) { - return this._server.cancelTask({ taskId }, options); - } -} -//# sourceMappingURL=server.js.map diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 7a6f98777..91ce6002b 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -6,10 +6,11 @@ */ import type { Server } from '../../server/server.js'; -import type { RequestOptions } from '../../../../core/src/index.js'; -import type { ResponseMessage } from '../../../../core/src/index.js'; -import type { AnySchema, SchemaOutput } from '../../../../core/src/index.js'; import type { + RequestOptions, + ResponseMessage, + AnySchema, + SchemaOutput, ServerRequest, Notification, Request, @@ -17,7 +18,7 @@ import type { GetTaskResult, ListTasksResult, CancelTaskResult -} from '../../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; /** * Experimental task features for low-level MCP servers. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 13d2af45c..57f90f2cd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,4 +14,4 @@ export * from './server/auth/index.js'; export * from './experimental/index.js'; // re-export shared types -export * from '../../core/src/index.js'; +export * from '@modelcontextprotocol/sdk-core'; diff --git a/packages/server/src/server/auth/clients.ts b/packages/server/src/server/auth/clients.ts index e4a10e3c6..60d587825 100644 --- a/packages/server/src/server/auth/clients.ts +++ b/packages/server/src/server/auth/clients.ts @@ -1,4 +1,4 @@ -import { OAuthClientInformationFull } from '../../../../core/src/index.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; /** * Stores information about registered OAuth clients for this server. diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts index 1b883e61a..bc3ed1a4f 100644 --- a/packages/server/src/server/auth/handlers/authorize.ts +++ b/packages/server/src/server/auth/handlers/authorize.ts @@ -1,10 +1,11 @@ -import { RequestHandler } from 'express'; +import type { RequestHandler } from 'express'; import * as z from 'zod/v4'; import express from 'express'; -import { OAuthServerProvider } from '../provider.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import type { OAuthServerProvider } from '../provider.js'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; +import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/sdk-core'; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts index 08370d37a..f521881a1 100644 --- a/packages/server/src/server/auth/handlers/metadata.ts +++ b/packages/server/src/server/auth/handlers/metadata.ts @@ -1,5 +1,6 @@ -import express, { RequestHandler } from 'express'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../../../core/src/index.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk-core'; import cors from 'cors'; import { allowedMethods } from '../middleware/allowedMethods.js'; diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts index 0fb69512e..3527ebc98 100644 --- a/packages/server/src/server/auth/handlers/register.ts +++ b/packages/server/src/server/auth/handlers/register.ts @@ -1,11 +1,19 @@ -import express, { RequestHandler } from 'express'; -import { OAuthClientInformationFull, OAuthClientMetadataSchema } from '../../../../../core/src/index.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; +import { + OAuthClientMetadataSchema, + InvalidClientMetadataError, + ServerError, + TooManyRequestsError, + OAuthError +} from '@modelcontextprotocol/sdk-core'; import crypto from 'node:crypto'; import cors from 'cors'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidClientMetadataError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; export type ClientRegistrationHandlerOptions = { /** diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts index ada7e4ac7..d7e889495 100644 --- a/packages/server/src/server/auth/handlers/revoke.ts +++ b/packages/server/src/server/auth/handlers/revoke.ts @@ -1,11 +1,18 @@ -import { OAuthServerProvider } from '../provider.js'; -import express, { RequestHandler } from 'express'; +import type { OAuthServerProvider } from '../provider.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; import cors from 'cors'; import { authenticateClient } from '../middleware/clientAuth.js'; -import { OAuthTokenRevocationRequestSchema } from '../../../../../core/src/index.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import { + OAuthTokenRevocationRequestSchema, + InvalidRequestError, + ServerError, + TooManyRequestsError, + OAuthError +} from '@modelcontextprotocol/sdk-core'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, ServerError, TooManyRequestsError, OAuthError } from '../../../../../core/src/index.js'; export type RevocationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts index 1ec27b3a8..afccdc2b8 100644 --- a/packages/server/src/server/auth/handlers/token.ts +++ b/packages/server/src/server/auth/handlers/token.ts @@ -1,10 +1,12 @@ import * as z from 'zod/v4'; -import express, { RequestHandler } from 'express'; -import { OAuthServerProvider } from '../provider.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { OAuthServerProvider } from '../provider.js'; import cors from 'cors'; import { verifyChallenge } from 'pkce-challenge'; import { authenticateClient } from '../middleware/clientAuth.js'; -import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; import { allowedMethods } from '../middleware/allowedMethods.js'; import { InvalidRequestError, @@ -13,7 +15,7 @@ import { ServerError, TooManyRequestsError, OAuthError -} from '../../../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; export type TokenHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts index 0f9d6be85..1cb16768f 100644 --- a/packages/server/src/server/auth/middleware/allowedMethods.ts +++ b/packages/server/src/server/auth/middleware/allowedMethods.ts @@ -1,5 +1,5 @@ -import { RequestHandler } from 'express'; -import { MethodNotAllowedError } from '../../../../../core/src/index.js'; +import type { RequestHandler } from 'express'; +import { MethodNotAllowedError } from '@modelcontextprotocol/sdk-core'; /** * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts index 015352623..fad637abb 100644 --- a/packages/server/src/server/auth/middleware/bearerAuth.ts +++ b/packages/server/src/server/auth/middleware/bearerAuth.ts @@ -1,7 +1,7 @@ -import { RequestHandler } from 'express'; -import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '../../../../../core/src/index.js'; -import { OAuthTokenVerifier } from '../provider.js'; -import { AuthInfo } from '../../../../../core/src/index.js'; +import type { RequestHandler } from 'express'; +import type { AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/sdk-core'; +import type { OAuthTokenVerifier } from '../provider.js'; export type BearerAuthMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts index 83e9bd593..b37d9d7cd 100644 --- a/packages/server/src/server/auth/middleware/clientAuth.ts +++ b/packages/server/src/server/auth/middleware/clientAuth.ts @@ -1,8 +1,8 @@ import * as z from 'zod/v4'; -import { RequestHandler } from 'express'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { OAuthClientInformationFull } from '../../../../../core/src/index.js'; -import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../../../../../core/src/index.js'; +import type { RequestHandler } from 'express'; +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; +import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '@modelcontextprotocol/sdk-core'; export type ClientAuthenticationMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts index f6ea59c50..8b5db3ae6 100644 --- a/packages/server/src/server/auth/provider.ts +++ b/packages/server/src/server/auth/provider.ts @@ -1,7 +1,6 @@ -import { Response } from 'express'; -import { OAuthRegisteredClientsStore } from './clients.js'; -import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../core/src/index.js'; -import { AuthInfo } from '../../../../core/src/index.js'; +import type { Response } from 'express'; +import type { OAuthRegisteredClientsStore } from './clients.js'; +import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens, AuthInfo } from '@modelcontextprotocol/sdk-core'; export type AuthorizationParams = { state?: string; diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts index 4ab90410f..3eb052905 100644 --- a/packages/server/src/server/auth/providers/proxyProvider.ts +++ b/packages/server/src/server/auth/providers/proxyProvider.ts @@ -1,16 +1,14 @@ -import { Response } from 'express'; -import { OAuthRegisteredClientsStore } from '../clients.js'; -import { +import type { Response } from 'express'; +import type { OAuthRegisteredClientsStore } from '../clients.js'; +import type { OAuthClientInformationFull, - OAuthClientInformationFullSchema, OAuthTokenRevocationRequest, OAuthTokens, - OAuthTokensSchema -} from '../../../../../core/src/index.js'; -import { AuthInfo } from '../../../../../core/src/index.js'; -import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; -import { ServerError } from '../../../../../core/src/index.js'; -import { FetchLike } from '../../../../../core/src/index.js'; + AuthInfo, + FetchLike +} from '@modelcontextprotocol/sdk-core'; +import { OAuthClientInformationFullSchema, OAuthTokensSchema, ServerError } from '@modelcontextprotocol/sdk-core'; +import type { AuthorizationParams, OAuthServerProvider } from '../provider.js'; export type ProxyEndpoints = { authorizationUrl: string; diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts index 926b2bf7d..a8bed2210 100644 --- a/packages/server/src/server/auth/router.ts +++ b/packages/server/src/server/auth/router.ts @@ -1,11 +1,16 @@ -import express, { RequestHandler } from 'express'; -import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './handlers/register.js'; -import { tokenHandler, TokenHandlerOptions } from './handlers/token.js'; -import { authorizationHandler, AuthorizationHandlerOptions } from './handlers/authorize.js'; -import { revocationHandler, RevocationHandlerOptions } from './handlers/revoke.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { ClientRegistrationHandlerOptions } from './handlers/register.js'; +import { clientRegistrationHandler } from './handlers/register.js'; +import type { TokenHandlerOptions } from './handlers/token.js'; +import { tokenHandler } from './handlers/token.js'; +import type { AuthorizationHandlerOptions } from './handlers/authorize.js'; +import { authorizationHandler } from './handlers/authorize.js'; +import type { RevocationHandlerOptions } from './handlers/revoke.js'; +import { revocationHandler } from './handlers/revoke.js'; import { metadataHandler } from './handlers/metadata.js'; -import { OAuthServerProvider } from './provider.js'; -import { OAuthMetadata, OAuthProtectedResourceMetadata } from '../../../../core/src/index.js'; +import type { OAuthServerProvider } from './provider.js'; +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk-core'; // Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) const allowInsecureIssuerUrl = diff --git a/packages/server/src/server/completable.ts b/packages/server/src/server/completable.ts index c7562aff3..72e191cc2 100644 --- a/packages/server/src/server/completable.ts +++ b/packages/server/src/server/completable.ts @@ -1,4 +1,4 @@ -import { AnySchema, SchemaInput } from '../../../core/src/index.js'; +import type { AnySchema, SchemaInput } from '@modelcontextprotocol/sdk-core'; export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); diff --git a/packages/server/src/server/express.ts b/packages/server/src/server/express.ts index a542acd7a..304dde79a 100644 --- a/packages/server/src/server/express.ts +++ b/packages/server/src/server/express.ts @@ -1,4 +1,5 @@ -import express, { Express } from 'express'; +import type { Express } from 'express'; +import express from 'express'; import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; /** diff --git a/packages/server/src/server/inMemoryEventStore.ts b/packages/server/src/server/inMemoryEventStore.ts index 3e7dc26d4..f4df657c9 100644 --- a/packages/server/src/server/inMemoryEventStore.ts +++ b/packages/server/src/server/inMemoryEventStore.ts @@ -1,4 +1,4 @@ -import { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; import type { EventStore } from './streamableHttp.js'; /** diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 455e46c9f..78a99f78f 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,41 +1,21 @@ -import { Server, ServerOptions } from './server.js'; -import { +import type { ServerOptions } from './server.js'; +import { Server } from './server.js'; +import type { AnySchema, AnyObjectSchema, ZodRawShapeCompat, SchemaOutput, ShapeOutput, - normalizeObjectSchema, - safeParseAsync, - getObjectShape, - objectFromShape, - getParseErrorMessage, - getSchemaDescription, - isSchemaOptional, - getLiteralValue, - toJsonSchemaCompat -} from '../../../core/src/index.js'; -import { Implementation, Tool, ListToolsResult, CallToolResult, - McpError, - ErrorCode, CompleteResult, PromptReference, ResourceTemplateReference, BaseMetadata, Resource, ListResourcesResult, - ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema, - ListToolsRequestSchema, - CallToolRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema, - CompleteRequestSchema, ListPromptsResult, Prompt, PromptArgument, @@ -49,17 +29,39 @@ import { Result, CompleteRequestPrompt, CompleteRequestResourceTemplate, + CallToolRequest, + ToolExecution, + Variables, + RequestHandlerExtra, + Transport +} from '@modelcontextprotocol/sdk-core'; +import { + normalizeObjectSchema, + safeParseAsync, + getObjectShape, + objectFromShape, + getParseErrorMessage, + getSchemaDescription, + isSchemaOptional, + getLiteralValue, + toJsonSchemaCompat, + McpError, + ErrorCode, + ListResourceTemplatesRequestSchema, + ReadResourceRequestSchema, + ListToolsRequestSchema, + CallToolRequestSchema, + ListResourcesRequestSchema, + ListPromptsRequestSchema, + GetPromptRequestSchema, + CompleteRequestSchema, assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, - CallToolRequest, - ToolExecution -} from '../../../core/src/index.js'; + UriTemplate, + validateAndWarnToolName +} from '@modelcontextprotocol/sdk-core'; import { isCompletable, getCompleter } from './completable.js'; -import { UriTemplate, Variables } from '../../../core/src/index.js'; -import { RequestHandlerExtra } from '../../../core/src/index.js'; -import { Transport } from '../../../core/src/index.js'; -import { validateAndWarnToolName } from '../../../core/src/index.js'; import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; import { ZodOptional } from 'zod'; diff --git a/packages/server/src/server/middleware/hostHeaderValidation.js b/packages/server/src/server/middleware/hostHeaderValidation.js deleted file mode 100644 index 6ef065074..000000000 --- a/packages/server/src/server/middleware/hostHeaderValidation.js +++ /dev/null @@ -1,77 +0,0 @@ -import { URL } from 'node:url'; - -/** - * 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 - * ```typescript - * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); - * app.use(middleware); - * ``` - */ -export function hostHeaderValidation(allowedHostnames) { - return (req, res, next) => { - const hostHeader = req.headers.host; - if (!hostHeader) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Missing Host header' - }, - id: null - }); - return; - } - // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) - let hostname; - try { - hostname = new URL(`http://${hostHeader}`).hostname; - } catch { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host header: ${hostHeader}` - }, - id: null - }); - return; - } - if (!allowedHostnames.includes(hostname)) { - res.status(403).json({ - jsonrpc: '2.0', - error: { - code: -32000, - message: `Invalid Host: ${hostname}` - }, - id: null - }); - return; - } - next(); - }; -} -/** - * Convenience middleware for localhost DNS rebinding protection. - * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. - * - * @example - * ```typescript - * app.use(localhostHostValidation()); - * ``` - */ -export function localhostHostValidation() { - return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); -} -//# sourceMappingURL=hostHeaderValidation.js.map diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts index 165003635..bb0bfb47f 100644 --- a/packages/server/src/server/middleware/hostHeaderValidation.ts +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction, RequestHandler } from 'express'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; /** * Express middleware for DNS rebinding protection. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 90b462be7..d791b72ab 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,67 +1,67 @@ +import type { + JsonSchemaType, + jsonSchemaValidator, + AnyObjectSchema, + SchemaOutput, + NotificationOptions, + ProtocolOptions, + RequestOptions, + ClientCapabilities, + CreateMessageRequest, + CreateMessageResult, + CreateMessageResultWithTools, + CreateMessageRequestParamsBase, + CreateMessageRequestParamsWithTools, + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + Implementation, + InitializeRequest, + InitializeResult, + ListRootsRequest, + LoggingLevel, + LoggingMessageNotification, + ResourceUpdatedNotification, + ServerCapabilities, + ServerNotification, + ServerRequest, + ServerResult, + ToolResultContent, + ToolUseContent, + Request, + Notification, + Result, + ZodV3Internal, + ZodV4Internal, + RequestHandlerExtra +} from '@modelcontextprotocol/sdk-core'; import { mergeCapabilities, Protocol, - type NotificationOptions, - type ProtocolOptions, - type RequestOptions -} from '../../../core/src/index.js'; -import { - type ClientCapabilities, - type CreateMessageRequest, - type CreateMessageResult, CreateMessageResultSchema, - type CreateMessageResultWithTools, CreateMessageResultWithToolsSchema, - type CreateMessageRequestParamsBase, - type CreateMessageRequestParamsWithTools, - type ElicitRequestFormParams, - type ElicitRequestURLParams, - type ElicitResult, ElicitResultSchema, EmptyResultSchema, ErrorCode, - type Implementation, InitializedNotificationSchema, - type InitializeRequest, InitializeRequestSchema, - type InitializeResult, LATEST_PROTOCOL_VERSION, - type ListRootsRequest, ListRootsResultSchema, - type LoggingLevel, LoggingLevelSchema, - type LoggingMessageNotification, McpError, - type ResourceUpdatedNotification, - type ServerCapabilities, - type ServerNotification, - type ServerRequest, - type ServerResult, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, - type ToolResultContent, - type ToolUseContent, CallToolRequestSchema, CallToolResultSchema, CreateTaskResultSchema, - type Request, - type Notification, - type Result -} from '../../../core/src/index.js'; -import { AjvJsonSchemaValidator } from '../../../core/src/index.js'; -import type { JsonSchemaType, jsonSchemaValidator } from '../../../core/src/index.js'; -import { - AnyObjectSchema, getObjectShape, isZ4Schema, safeParse, - SchemaOutput, - type ZodV3Internal, - type ZodV4Internal -} from '../../../core/src/index.js'; -import { RequestHandlerExtra } from '../../../core/src/index.js'; + AjvJsonSchemaValidator, + assertToolsCallTaskCapability, + assertClientRequestTaskCapability +} from '@modelcontextprotocol/sdk-core'; import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; -import { assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '../../../core/src/index.js'; export type ServerOptions = ProtocolOptions & { /** diff --git a/packages/server/src/server/sse.ts b/packages/server/src/server/sse.ts index 724c3b4cb..02a06709c 100644 --- a/packages/server/src/server/sse.ts +++ b/packages/server/src/server/sse.ts @@ -1,10 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../../../core/src/index.js'; -import { JSONRPCMessage, JSONRPCMessageSchema, MessageExtraInfo, RequestInfo } from '../../../core/src/index.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Transport, JSONRPCMessage, MessageExtraInfo, RequestInfo, AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; import getRawBody from 'raw-body'; import contentType from 'content-type'; -import { AuthInfo } from '../../../core/src/index.js'; import { URL } from 'node:url'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 7fd0472bc..5bf5657be 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,8 +1,7 @@ import process from 'node:process'; -import { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '../../../core/src/index.js'; -import { JSONRPCMessage } from '../../../core/src/index.js'; -import { Transport } from '../../../core/src/index.js'; +import type { Readable, Writable } from 'node:stream'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 6e10827dd..5135caf48 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -1,22 +1,17 @@ -import { IncomingMessage, ServerResponse } from 'node:http'; -import { Transport } from '../../../core/src/index.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Transport, MessageExtraInfo, RequestInfo, JSONRPCMessage, RequestId, AuthInfo } from '@modelcontextprotocol/sdk-core'; import { - MessageExtraInfo, - RequestInfo, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, - JSONRPCMessage, JSONRPCMessageSchema, - RequestId, SUPPORTED_PROTOCOL_VERSIONS, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isJSONRPCErrorResponse -} from '../../../core/src/index.js'; +} from '@modelcontextprotocol/sdk-core'; import getRawBody from 'raw-body'; import contentType from 'content-type'; import { randomUUID } from 'node:crypto'; -import { AuthInfo } from '../../../core/src/index.js'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9531a59f2..cc5844945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -145,6 +145,12 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.1) + eslint-import-resolver-typescript: + specifier: ^4.4.4 + version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) eslint-plugin-n: specifier: ^17.23.1 version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) @@ -629,6 +635,15 @@ packages: '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -842,6 +857,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -959,9 +977,15 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -1004,6 +1028,9 @@ packages: '@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==} @@ -1135,6 +1162,101 @@ packages: resolution: {integrity: sha512-v1SNmHbuTYMEIAAJZ5OgKY5kMIgDnS/aVTsP9FdR9FgqyZqgUbA2eHOjjMQHVw/XBLS5ZA32kkGt7cH8RzMlOA==} 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] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@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] + '@vitest/expect@4.0.9': resolution: {integrity: sha512-C2vyXf5/Jfj1vl4DQYxjib3jzyuswMi/KHHVN2z+H4v16hdJ7jMZ0OGe3uOVIt6LyJsAofDdaJNIFEpQcrSTFw==} @@ -1199,6 +1321,30 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + 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.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==} @@ -1206,9 +1352,17 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + 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==} + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1230,6 +1384,10 @@ packages: 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'} @@ -1290,6 +1448,26 @@ packages: 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'} + + 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'} @@ -1302,6 +1480,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1313,6 +1499,10 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1328,6 +1518,10 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1347,6 +1541,14 @@ packages: 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.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1371,12 +1573,68 @@ packages: 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.23.1: resolution: {integrity: sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1497,6 +1755,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -1521,6 +1783,17 @@ packages: 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'} @@ -1529,6 +1802,10 @@ packages: 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.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} @@ -1544,6 +1821,10 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -1554,10 +1835,21 @@ packages: 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'} @@ -1601,21 +1893,119 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.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-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-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-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-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-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'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1641,6 +2031,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1702,6 +2096,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1710,6 +2107,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + 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==} @@ -1725,6 +2127,26 @@ packages: 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'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1736,6 +2158,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1760,6 +2186,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -1777,6 +2206,10 @@ packages: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1810,6 +2243,14 @@ packages: resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} engines: {node: '>= 0.10'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1821,6 +2262,11 @@ packages: 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 + rollup@4.53.2: resolution: {integrity: sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1830,12 +2276,28 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + 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'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -1849,6 +2311,18 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} + 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==} @@ -1883,6 +2357,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + 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==} @@ -1897,6 +2375,26 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + 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'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1913,6 +2411,10 @@ packages: 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.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -1956,6 +2458,12 @@ packages: typescript: optional: true + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.6: resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} @@ -1969,18 +2477,38 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.49.0: - resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + 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'} + + typescript-eslint@8.49.0: + resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1988,6 +2516,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2077,6 +2608,22 @@ packages: jsdom: optional: true + 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.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2122,6 +2669,22 @@ snapshots: '@cfworker/json-schema@4.1.1': {} + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -2259,6 +2822,13 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@noble/hashes@1.8.0': {} '@paralleldrive/cuid2@2.3.1': @@ -2331,8 +2901,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.2': optional: true + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.0.0': {} + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -2382,6 +2959,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json5@0.0.29': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -2547,6 +3126,65 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20251108.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20251108.1 + '@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 + '@vitest/expect@4.0.9': dependencies: '@standard-schema/spec': 1.0.0 @@ -2621,12 +3259,68 @@ snapshots: argparse@2.0.1: {} + 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.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.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.0 + 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.0 + 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.0 + 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.0 + 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: {} + async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + balanced-match@1.0.2: {} body-parser@2.2.0: @@ -2659,6 +3353,13 @@ snapshots: 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 @@ -2710,12 +3411,46 @@ snapshots: 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 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 deep-is@0.1.4: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -2725,6 +3460,10 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2740,6 +3479,63 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + es-abstract@1.24.0: + 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.19 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2757,6 +3553,16 @@ snapshots: 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.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2799,6 +3605,47 @@ snapshots: dependencies: eslint: 9.39.1 + eslint-import-context@0.1.9(unrs-resolver@1.11.1): + dependencies: + get-tsconfig: 4.13.0 + 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.1): + dependencies: + debug: 4.4.3 + eslint: 9.39.1 + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + get-tsconfig: 4.13.0 + 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.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.49.0(eslint@9.39.1)(typescript@5.9.3) + eslint: 9.39.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.1) + transitivePeerDependencies: + - supports-color + eslint-plugin-es-x@7.8.0(eslint@9.39.1): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) @@ -2806,6 +3653,35 @@ snapshots: eslint: 9.39.1 eslint-compat-utils: 0.5.1(eslint@9.39.1) + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1): + 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.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + 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.49.0(eslint@9.39.1)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-n@17.23.1(eslint@9.39.1)(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) @@ -2978,6 +3854,10 @@ snapshots: flatted@3.3.3: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -3001,6 +3881,19 @@ snapshots: 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 @@ -3019,6 +3912,12 @@ snapshots: 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.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -3031,14 +3930,29 @@ snapshots: globals@15.15.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.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: @@ -3078,16 +3992,128 @@ snapshots: inherits@2.0.4: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + ipaddr.js@1.9.1: {} + 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-bun-module@2.0.0: + dependencies: + semver: 7.7.3 + + 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-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-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-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-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.19 + + 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 + + isarray@2.0.5: {} + isexe@2.0.0: {} jose@6.1.3: {} @@ -3106,6 +4132,10 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@1.0.2: + dependencies: + minimist: 1.2.8 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3155,10 +4185,14 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + ms@2.1.3: {} nanoid@3.3.11: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -3167,6 +4201,37 @@ snapshots: 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.0 + 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.0 + + 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 + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3184,6 +4249,12 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -3202,6 +4273,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -3212,6 +4285,8 @@ snapshots: pkce-challenge@5.0.0: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -3242,12 +4317,38 @@ snapshots: iconv-lite: 0.7.0 unpipe: 1.0.0 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + 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.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 + require-from-string@2.0.2: {} resolve-from@4.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 + rollup@4.53.2: dependencies: '@types/estree': 1.0.8 @@ -3286,10 +4387,31 @@ snapshots: transitivePeerDependencies: - supports-color + 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 + safer-buffer@2.1.2: {} + semver@6.3.1: {} + semver@7.7.3: {} send@1.2.0: @@ -3317,6 +4439,28 @@ snapshots: transitivePeerDependencies: - supports-color + 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: {} shebang-command@2.0.0: @@ -3357,6 +4501,8 @@ snapshots: source-map-js@1.2.1: {} + stable-hash-x@0.2.0: {} + stackback@0.0.2: {} statuses@2.0.1: {} @@ -3365,6 +4511,36 @@ snapshots: std-env@3.10.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.0 + 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 + + strip-bom@3.0.0: {} + strip-json-comments@3.1.1: {} superagent@10.2.3: @@ -3392,6 +4568,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + tapable@2.3.0: {} tinybench@2.9.0: {} @@ -3420,6 +4598,16 @@ snapshots: 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 + + tslib@2.8.1: + optional: true + tsx@4.20.6: dependencies: esbuild: 0.25.12 @@ -3437,6 +4625,39 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 + 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 + typescript-eslint@8.49.0(eslint@9.39.1)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) @@ -3450,10 +4671,41 @@ snapshots: typescript@5.9.3: {} + 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 + undici-types@7.16.0: {} 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 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3522,6 +4774,47 @@ snapshots: - tsx - yaml + 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.19 + + 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.19: + 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 From c9fcc39a28b99325637e32e6d7585aad4786cb6f Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 14:10:18 +0200 Subject: [PATCH 17/22] test fix --- packages/examples/shared/src/demoInMemoryOAuthProvider.ts | 2 ++ packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts index aef51ecf0..ed24ebcbd 100644 --- a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts +++ b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts @@ -7,6 +7,8 @@ import type { OAuthMetadata, OAuthTokens, AuthInfo, +} from '@modelcontextprotocol/sdk-server'; +import { createOAuthMetadata, mcpAuthRouter, resourceUrlFromServerUrl, diff --git a/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts b/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts index 6909ca61e..90710af04 100644 --- a/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts +++ b/packages/examples/shared/test/demoInMemoryOAuthProvider.test.ts @@ -1,4 +1,4 @@ -import { Response } from 'express'; +import type { Response } from 'express'; import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from '../src/demoInMemoryOAuthProvider.js'; import type { AuthorizationParams } from '@modelcontextprotocol/sdk-server'; import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; From f9399004b25d34798fac0b50bbf2ab7f5e7d1c52 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 14:15:54 +0200 Subject: [PATCH 18/22] imports fix --- .github/workflows/main.yml | 1 - common/eslint-config/eslint.config.mjs | 8 +-- packages/client/src/client/client.ts | 60 ++++++++++--------- .../client/src/experimental/tasks/client.ts | 6 +- .../shared/src/demoInMemoryOAuthProvider.ts | 9 +-- packages/integration/test/server.test.ts | 18 +++--- 6 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index aae940789..9ac01bfa6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,7 +29,6 @@ jobs: cache: pnpm cache-dependency-path: pnpm-lock.yaml - - run: pnpm install - run: pnpm run check:all - run: pnpm run build:all diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index b6bf33f50..b75379c4d 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -35,14 +35,14 @@ export default tseslint.config( // while the actual file is "./foo.ts" extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], // Use the tsconfig in each package root (when running ESLint from that package) - project: 'tsconfig.json', - }, - }, + project: 'tsconfig.json' + } + } }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'n/prefer-node-protocol': 'error', - '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }] } }, { diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index fcc1603a3..cda011539 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -7,43 +7,51 @@ import type { AnyObjectSchema, SchemaOutput, RequestHandlerExtra, + ProtocolOptions, + RequestOptions, + CallToolRequest, + ClientCapabilities, + ClientNotification, + ClientRequest, + ClientResult, + CompatibilityCallToolResultSchema, + CompleteRequest, + GetPromptRequest, + Implementation, + ListPromptsRequest, + ListResourcesRequest, + ListResourceTemplatesRequest, + ListToolsRequest, + LoggingLevel, + ReadResourceRequest, + ServerCapabilities, + SubscribeRequest, + Tool, + UnsubscribeRequest, + ListChangedHandlers, + Request, + Notification, + Result, + ZodV3Internal, + ZodV4Internal +} from '@modelcontextprotocol/sdk-core'; +import { mergeCapabilities, Protocol, - type ProtocolOptions, - type RequestOptions, - type CallToolRequest, CallToolResultSchema, - type ClientCapabilities, - type ClientNotification, - type ClientRequest, - type ClientResult, - type CompatibilityCallToolResultSchema, - type CompleteRequest, CompleteResultSchema, EmptyResultSchema, ErrorCode, - type GetPromptRequest, GetPromptResultSchema, - type Implementation, InitializeResultSchema, LATEST_PROTOCOL_VERSION, - type ListPromptsRequest, ListPromptsResultSchema, - type ListResourcesRequest, ListResourcesResultSchema, - type ListResourceTemplatesRequest, ListResourceTemplatesResultSchema, - type ListToolsRequest, ListToolsResultSchema, - type LoggingLevel, McpError, - type ReadResourceRequest, ReadResourceResultSchema, - type ServerCapabilities, SUPPORTED_PROTOCOL_VERSIONS, - type SubscribeRequest, - type Tool, - type UnsubscribeRequest, ElicitResultSchema, ElicitRequestSchema, CreateTaskResultSchema, @@ -53,18 +61,14 @@ import type { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ListChangedOptionsBaseSchema, - type ListChangedHandlers, - type Request, - type Notification, - type Result, getObjectShape, isZ4Schema, safeParse, - type ZodV3Internal, - type ZodV4Internal + AjvJsonSchemaValidator, + assertToolsCallTaskCapability, + assertClientRequestTaskCapability } from '@modelcontextprotocol/sdk-core'; -import { AjvJsonSchemaValidator, assertToolsCallTaskCapability, assertClientRequestTaskCapability } from '@modelcontextprotocol/sdk-core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; /** diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index fd8a2b4a0..5f059f4d6 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -16,14 +16,12 @@ import type { Notification, Request, Result, - CallToolResultSchema, - type CompatibilityCallToolResultSchema, - McpError, - ErrorCode, + CompatibilityCallToolResultSchema, GetTaskResult, ListTasksResult, CancelTaskResult } from '@modelcontextprotocol/sdk-core'; +import { CallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk-core'; /** * Internal interface for accessing Client's private methods. diff --git a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts index ed24ebcbd..0087019c1 100644 --- a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts +++ b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts @@ -6,14 +6,9 @@ import type { OAuthClientInformationFull, OAuthMetadata, OAuthTokens, - AuthInfo, -} from '@modelcontextprotocol/sdk-server'; -import { - createOAuthMetadata, - mcpAuthRouter, - resourceUrlFromServerUrl, - InvalidRequestError + AuthInfo } from '@modelcontextprotocol/sdk-server'; +import { createOAuthMetadata, mcpAuthRouter, resourceUrlFromServerUrl, InvalidRequestError } from '@modelcontextprotocol/sdk-server'; import type { Request, Response } from 'express'; import express from 'express'; diff --git a/packages/integration/test/server.test.ts b/packages/integration/test/server.test.ts index 0b36ba7c6..f833bfe21 100644 --- a/packages/integration/test/server.test.ts +++ b/packages/integration/test/server.test.ts @@ -1,9 +1,18 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import supertest from 'supertest'; import { Client } from '@modelcontextprotocol/sdk-client'; -import { InMemoryTransport, CallToolRequestSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk-core'; import type { Transport, + LoggingMessageNotification, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + AnyObjectSchema +} from '@modelcontextprotocol/sdk-core'; +import { + InMemoryTransport, + CallToolRequestSchema, + CallToolResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, ElicitRequestSchema, @@ -14,18 +23,13 @@ import type { ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, - type LoggingMessageNotification, McpError, NotificationSchema, RequestSchema, ResultSchema, SetLevelRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, - CreateTaskResultSchema, - JsonSchemaType, - JsonSchemaValidator, - jsonSchemaValidator, - AnyObjectSchema + CreateTaskResultSchema } from '@modelcontextprotocol/sdk-core'; import { Server, McpServer, InMemoryTaskStore, InMemoryTaskMessageQueue, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; import * as z3 from 'zod/v3'; From d9cd7fed4f94bc163c90235b53678511af50c27c Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 14:17:38 +0200 Subject: [PATCH 19/22] imports fix --- packages/client/src/client/websocket.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index ccc26a88e..339567fe1 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,4 +1,5 @@ -import type { Transport, JSONRPCMessage, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; +import type { Transport, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; const SUBPROTOCOL = 'mcp'; From f05736301365720dec727984625b56b2f4ff1d69 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 14:22:42 +0200 Subject: [PATCH 20/22] import sorting --- common/eslint-config/eslint.config.mjs | 8 +- common/eslint-config/package.json | 5 +- packages/client/src/client/auth-extensions.ts | 3 +- packages/client/src/client/auth.ts | 28 +++---- packages/client/src/client/client.ts | 58 ++++++------- packages/client/src/client/middleware.ts | 3 +- packages/client/src/client/sse.ts | 7 +- packages/client/src/client/stdio.ts | 5 +- packages/client/src/client/streamableHttp.ts | 12 +-- packages/client/src/client/websocket.ts | 2 +- .../client/src/experimental/tasks/client.ts | 19 +++-- packages/client/src/index.ts | 4 +- .../core/src/experimental/tasks/interfaces.ts | 18 ++-- .../experimental/tasks/stores/in-memory.ts | 7 +- packages/core/src/index.ts | 15 ++-- packages/core/src/shared/protocol.ts | 52 ++++++------ packages/core/src/shared/responseMessage.ts | 2 +- packages/core/src/util/inMemory.ts | 2 +- packages/core/src/util/zod-compat.ts | 3 +- .../core/src/util/zod-json-schema-compat.ts | 7 +- packages/core/src/validation/ajv-provider.ts | 3 +- .../core/src/validation/cfworker-provider.ts | 3 +- .../client/src/elicitationUrlExample.ts | 30 +++---- .../client/src/multipleClientsParallel.ts | 6 +- .../client/src/parallelToolCallsClient.ts | 8 +- .../client/src/simpleClientCredentials.ts | 2 +- .../examples/client/src/simpleOAuthClient.ts | 10 ++- .../client/src/simpleOAuthClientProvider.ts | 2 +- .../client/src/simpleStreamableHttp.ts | 29 +++---- .../client/src/simpleTaskInteractiveClient.ts | 13 +-- .../examples/client/src/ssePollingClient.ts | 6 +- .../streamableHttpWithSseFallbackClient.ts | 10 +-- .../server/src/elicitationFormExample.ts | 3 +- .../server/src/elicitationUrlExample.ts | 23 ++--- .../examples/server/src/inMemoryEventStore.ts | 2 +- .../server/src/jsonResponseStreamableHttp.ts | 5 +- .../examples/server/src/simpleSseServer.ts | 4 +- .../src/simpleStatelessStreamableHttp.ts | 4 +- .../server/src/simpleStreamableHttp.ts | 28 ++++--- .../server/src/simpleTaskInteractive.ts | 43 +++++----- .../sseAndStreamableHttpCompatibleServer.ts | 10 ++- .../examples/server/src/ssePollingExample.ts | 8 +- .../src/standaloneSseWithGetStreamableHttp.ts | 5 +- .../shared/src/demoInMemoryOAuthProvider.ts | 11 +-- .../test/__fixtures__/serverThatHangs.ts | 3 +- .../integration/test/client/client.test.ts | 30 +++---- .../experimental/tasks/task-listing.test.ts | 3 +- .../test/experimental/tasks/task.test.ts | 2 +- packages/integration/test/helpers/http.ts | 3 +- packages/integration/test/helpers/mcp.ts | 4 +- .../integration/test/processCleanup.test.ts | 3 +- packages/integration/test/server.test.ts | 18 ++-- .../test/server/elicitation.test.ts | 6 +- packages/integration/test/server/mcp.test.ts | 11 +-- .../stateManagementStreamableHttp.test.ts | 16 ++-- .../integration/test/taskLifecycle.test.ts | 16 ++-- .../integration/test/taskResumability.test.ts | 12 +-- packages/integration/test/title.test.ts | 5 +- .../server/src/experimental/tasks/index.ts | 2 +- .../src/experimental/tasks/interfaces.ts | 11 +-- .../src/experimental/tasks/mcp-server.ts | 5 +- .../server/src/experimental/tasks/server.ts | 17 ++-- packages/server/src/index.ts | 2 +- .../src/server/auth/handlers/authorize.ts | 7 +- .../src/server/auth/handlers/metadata.ts | 5 +- .../src/server/auth/handlers/register.ts | 16 ++-- .../server/src/server/auth/handlers/revoke.ts | 17 ++-- .../server/src/server/auth/handlers/token.ts | 27 +++--- packages/server/src/server/auth/index.ts | 7 +- .../server/auth/middleware/allowedMethods.ts | 2 +- .../src/server/auth/middleware/bearerAuth.ts | 3 +- .../src/server/auth/middleware/clientAuth.ts | 7 +- packages/server/src/server/auth/provider.ts | 3 +- .../server/auth/providers/proxyProvider.ts | 11 +-- packages/server/src/server/auth/router.ts | 13 +-- packages/server/src/server/express.ts | 1 + .../server/src/server/inMemoryEventStore.ts | 1 + packages/server/src/server/mcp.ts | 84 +++++++++---------- .../server/middleware/hostHeaderValidation.ts | 2 +- packages/server/src/server/server.ts | 51 +++++------ packages/server/src/server/sse.ts | 7 +- packages/server/src/server/stdio.ts | 3 +- packages/server/src/server/streamableHttp.ts | 13 +-- pnpm-lock.yaml | 12 +++ 84 files changed, 529 insertions(+), 460 deletions(-) diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index b75379c4d..91640ea37 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -7,6 +7,7 @@ import nodePlugin from 'eslint-plugin-n'; import importPlugin from 'eslint-plugin-import'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -26,7 +27,8 @@ export default tseslint.config( reportUnusedDisableDirectives: false }, plugins: { - n: nodePlugin + n: nodePlugin, + 'simple-import-sort': simpleImportSortPlugin }, settings: { 'import/resolver': { @@ -42,7 +44,9 @@ export default tseslint.config( rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'n/prefer-node-protocol': 'error', - '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }] + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], + 'simple-import-sort/imports': 'warn', + 'simple-import-sort/exports': 'warn' } }, { diff --git a/common/eslint-config/package.json b/common/eslint-config/package.json index 05db982e4..5f67d650e 100644 --- a/common/eslint-config/package.json +++ b/common/eslint-config/package.json @@ -25,11 +25,12 @@ "@eslint/js": "^9.39.1", "eslint": "^9.8.0", "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-n": "^17.23.1", + "eslint-plugin-simple-import-sort": "^12.1.1", "prettier": "3.6.2", "typescript": "^5.5.4", - "typescript-eslint": "^8.48.1", - "eslint-import-resolver-typescript": "^4.4.4" + "typescript-eslint": "^8.48.1" } } diff --git a/packages/client/src/client/auth-extensions.ts b/packages/client/src/client/auth-extensions.ts index 0d17959d5..6155d749f 100644 --- a/packages/client/src/client/auth-extensions.ts +++ b/packages/client/src/client/auth-extensions.ts @@ -5,8 +5,9 @@ * for common machine-to-machine authentication scenarios. */ -import type { CryptoKey, JWK } from 'jose'; import type { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-core'; +import type { CryptoKey, JWK } from 'jose'; + import type { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index fb5e02844..1bf9ca98e 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1,33 +1,33 @@ -import pkceChallenge from 'pkce-challenge'; import type { - OAuthClientMetadata, + AuthorizationServerMetadata, + FetchLike, OAuthClientInformation, + OAuthClientInformationFull, OAuthClientInformationMixed, - OAuthTokens, + OAuthClientMetadata, OAuthMetadata, - OAuthClientInformationFull, OAuthProtectedResourceMetadata, - AuthorizationServerMetadata, - FetchLike + OAuthTokens } from '@modelcontextprotocol/sdk-core'; import { - LATEST_PROTOCOL_VERSION, - OAuthErrorResponseSchema, - OpenIdProviderDiscoveryMetadataSchema, - OAuthClientInformationFullSchema, - OAuthMetadataSchema, - OAuthProtectedResourceMetadataSchema, - OAuthTokensSchema, checkResourceAllowed, - resourceUrlFromServerUrl, InvalidClientError, InvalidClientMetadataError, InvalidGrantError, + LATEST_PROTOCOL_VERSION, OAUTH_ERRORS, + OAuthClientInformationFullSchema, OAuthError, + OAuthErrorResponseSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, + OAuthTokensSchema, + OpenIdProviderDiscoveryMetadataSchema, + resourceUrlFromServerUrl, ServerError, UnauthorizedClientError } from '@modelcontextprotocol/sdk-core'; +import pkceChallenge from 'pkce-challenge'; /** * Function type for adding client authentication to token requests. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index cda011539..db19b4e23 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1,14 +1,5 @@ import type { - Transport, - ListChangedOptions, - JsonSchemaType, - JsonSchemaValidator, - jsonSchemaValidator, AnyObjectSchema, - SchemaOutput, - RequestHandlerExtra, - ProtocolOptions, - RequestOptions, CallToolRequest, ClientCapabilities, ClientNotification, @@ -18,55 +9,64 @@ import type { CompleteRequest, GetPromptRequest, Implementation, + JsonSchemaType, + JsonSchemaValidator, + jsonSchemaValidator, + ListChangedHandlers, + ListChangedOptions, ListPromptsRequest, ListResourcesRequest, ListResourceTemplatesRequest, ListToolsRequest, LoggingLevel, + Notification, + ProtocolOptions, ReadResourceRequest, + Request, + RequestHandlerExtra, + RequestOptions, + Result, + SchemaOutput, ServerCapabilities, SubscribeRequest, Tool, + Transport, UnsubscribeRequest, - ListChangedHandlers, - Request, - Notification, - Result, ZodV3Internal, ZodV4Internal } from '@modelcontextprotocol/sdk-core'; import { - mergeCapabilities, - Protocol, + AjvJsonSchemaValidator, + assertClientRequestTaskCapability, + assertToolsCallTaskCapability, CallToolResultSchema, CompleteResultSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + CreateTaskResultSchema, + ElicitRequestSchema, + ElicitResultSchema, EmptyResultSchema, ErrorCode, + getObjectShape, GetPromptResultSchema, InitializeResultSchema, + isZ4Schema, LATEST_PROTOCOL_VERSION, + ListChangedOptionsBaseSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListToolsResultSchema, McpError, - ReadResourceResultSchema, - SUPPORTED_PROTOCOL_VERSIONS, - ElicitResultSchema, - ElicitRequestSchema, - CreateTaskResultSchema, - CreateMessageRequestSchema, - CreateMessageResultSchema, - ToolListChangedNotificationSchema, + mergeCapabilities, PromptListChangedNotificationSchema, + Protocol, + ReadResourceResultSchema, ResourceListChangedNotificationSchema, - ListChangedOptionsBaseSchema, - getObjectShape, - isZ4Schema, safeParse, - AjvJsonSchemaValidator, - assertToolsCallTaskCapability, - assertClientRequestTaskCapability + SUPPORTED_PROTOCOL_VERSIONS, + ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk-core'; import { ExperimentalClientTasks } from '../experimental/tasks/client.js'; diff --git a/packages/client/src/client/middleware.ts b/packages/client/src/client/middleware.ts index bbb77be47..e3d831f43 100644 --- a/packages/client/src/client/middleware.ts +++ b/packages/client/src/client/middleware.ts @@ -1,6 +1,7 @@ +import type { FetchLike } from '@modelcontextprotocol/sdk-core'; + import type { OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; -import type { FetchLike } from '@modelcontextprotocol/sdk-core'; /** * Middleware function that wraps and enhances fetch functionality. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index fec8bf7cc..240a361e6 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -1,6 +1,7 @@ -import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import type { Transport, FetchLike, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; -import { createFetchWithInit, normalizeHeaders, JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; +import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; +import { createFetchWithInit, JSONRPCMessageSchema, normalizeHeaders } from '@modelcontextprotocol/sdk-core'; +import { type ErrorEvent, EventSource, type EventSourceInit } from 'eventsource'; + import type { AuthResult, OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index 371878d14..ea4b41a98 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -1,10 +1,11 @@ import type { ChildProcess, IOType } from 'node:child_process'; -import spawn from 'cross-spawn'; import process from 'node:process'; import type { Stream } from 'node:stream'; import { PassThrough } from 'node:stream'; -import type { Transport, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; + +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; +import spawn from 'cross-spawn'; export type StdioServerParameters = { /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 5a475abf0..100c110fa 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,16 +1,18 @@ -import type { Transport, FetchLike, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import type { ReadableWritablePair } from 'node:stream/web'; + +import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; import { createFetchWithInit, - normalizeHeaders, isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, - JSONRPCMessageSchema + JSONRPCMessageSchema, + normalizeHeaders } from '@modelcontextprotocol/sdk-core'; +import { EventSourceParserStream } from 'eventsource-parser/stream'; + import type { AuthResult, OAuthClientProvider } from './auth.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; -import { EventSourceParserStream } from 'eventsource-parser/stream'; -import type { ReadableWritablePair } from 'node:stream/web'; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { diff --git a/packages/client/src/client/websocket.ts b/packages/client/src/client/websocket.ts index 339567fe1..29de274b7 100644 --- a/packages/client/src/client/websocket.ts +++ b/packages/client/src/client/websocket.ts @@ -1,4 +1,4 @@ -import type { Transport, JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; const SUBPROTOCOL = 'mcp'; diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 5f059f4d6..41f5989cb 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -5,23 +5,24 @@ * @experimental */ -import type { Client } from '../../client/client.js'; import type { - RequestOptions, - ResponseMessage, AnyObjectSchema, - SchemaOutput, CallToolRequest, + CancelTaskResult, ClientRequest, - Notification, - Request, - Result, CompatibilityCallToolResultSchema, GetTaskResult, ListTasksResult, - CancelTaskResult + Notification, + Request, + RequestOptions, + ResponseMessage, + Result, + SchemaOutput } from '@modelcontextprotocol/sdk-core'; -import { CallToolResultSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk-core'; +import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk-core'; + +import type { Client } from '../../client/client.js'; /** * Internal interface for accessing Client's private methods. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ea7fdd1f5..6dea7f8a2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,6 +1,6 @@ -export * from './client/client.js'; -export * from './client/auth-extensions.js'; export * from './client/auth.js'; +export * from './client/auth-extensions.js'; +export * from './client/client.js'; export * from './client/middleware.js'; export * from './client/sse.js'; export * from './client/stdio.js'; diff --git a/packages/core/src/experimental/tasks/interfaces.ts b/packages/core/src/experimental/tasks/interfaces.ts index 6fd439fa7..c1901d70a 100644 --- a/packages/core/src/experimental/tasks/interfaces.ts +++ b/packages/core/src/experimental/tasks/interfaces.ts @@ -3,20 +3,20 @@ * WARNING: These APIs are experimental and may change without notice. */ +import type { RequestHandlerExtra, RequestTaskStore } from '../../shared/protocol.js'; import type { - Task, - RequestId, - Result, - JSONRPCRequest, + JSONRPCErrorResponse, JSONRPCNotification, + JSONRPCRequest, JSONRPCResultResponse, - JSONRPCErrorResponse, - ServerRequest, + Request, + RequestId, + Result, ServerNotification, - ToolExecution, - Request + ServerRequest, + Task, + ToolExecution } from '../../types/types.js'; -import type { RequestHandlerExtra, RequestTaskStore } from '../../shared/protocol.js'; // ============================================================================ // Task Handler Types (for registerToolTask) diff --git a/packages/core/src/experimental/tasks/stores/in-memory.ts b/packages/core/src/experimental/tasks/stores/in-memory.ts index 9fcae408c..42ddf5bf4 100644 --- a/packages/core/src/experimental/tasks/stores/in-memory.ts +++ b/packages/core/src/experimental/tasks/stores/in-memory.ts @@ -5,11 +5,12 @@ * @experimental */ -import type { Task, RequestId, Result, Request } from '../../../types/types.js'; -import type { TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../interfaces.js'; -import { isTerminal } from '../interfaces.js'; import { randomBytes } from 'node:crypto'; +import type { Request, RequestId, Result, Task } from '../../../types/types.js'; +import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../interfaces.js'; +import { isTerminal } from '../interfaces.js'; + interface StoredTask { task: Task; request: Request; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b7da6aae3..f4eaeabfc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,6 @@ -export * from './util/inMemory.js'; -export * from './util/zod-compat.js'; -export * from './util/zod-json-schema-compat.js'; - -export * from './shared/auth-utils.js'; +export * from './auth/errors.js'; export * from './shared/auth.js'; +export * from './shared/auth-utils.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; @@ -11,13 +8,13 @@ export * from './shared/stdio.js'; export * from './shared/toolNameValidation.js'; export * from './shared/transport.js'; export * from './shared/uriTemplate.js'; - export * from './types/types.js'; -export * from './auth/errors.js'; +export * from './util/inMemory.js'; +export * from './util/zod-compat.js'; +export * from './util/zod-json-schema-compat.js'; // experimental exports export * from './experimental/index.js'; - export * from './validation/ajv-provider.js'; export * from './validation/cfworker-provider.js'; /** @@ -49,4 +46,4 @@ export * from './validation/cfworker-provider.js'; */ // Core types only - implementations are exported via separate entry points -export type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './validation/types.js'; +export type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './validation/types.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 37136a48d..9c65015d1 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -1,59 +1,59 @@ -import type { AnySchema, AnyObjectSchema, SchemaOutput } from '../util/zod-compat.js'; -import { safeParse } from '../util/zod-compat.js'; +import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js'; +import { isTerminal } from '../experimental/tasks/interfaces.js'; import type { + AuthInfo, + CancelledNotification, ClientCapabilities, - GetTaskRequest, GetTaskPayloadRequest, + GetTaskRequest, + GetTaskResult, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + JSONRPCResultResponse, + MessageExtraInfo, + Notification, Progress, ProgressNotification, + RelatedTaskMetadata, + Request, RequestId, + RequestInfo, + RequestMeta, Result, ServerCapabilities, - RequestMeta, - MessageExtraInfo, - RequestInfo, - GetTaskResult, - TaskCreationParams, - RelatedTaskMetadata, - CancelledNotification, Task, - TaskStatusNotification, - Request, - Notification, - JSONRPCResultResponse, - AuthInfo + TaskCreationParams, + TaskStatusNotification } from '../types/types.js'; import { CancelledNotificationSchema, + CancelTaskRequestSchema, + CancelTaskResultSchema, CreateTaskResultSchema, ErrorCode, + GetTaskPayloadRequestSchema, GetTaskRequestSchema, GetTaskResultSchema, - GetTaskPayloadRequestSchema, - ListTasksRequestSchema, - ListTasksResultSchema, - CancelTaskRequestSchema, - CancelTaskResultSchema, isJSONRPCErrorResponse, + isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, - isJSONRPCNotification, + isTaskAugmentedRequestParams, + ListTasksRequestSchema, + ListTasksResultSchema, McpError, PingRequestSchema, ProgressNotificationSchema, RELATED_TASK_META_KEY, - TaskStatusNotificationSchema, - isTaskAugmentedRequestParams + TaskStatusNotificationSchema } from '../types/types.js'; -import type { Transport, TransportSendOptions } from './transport.js'; -import type { TaskStore, TaskMessageQueue, QueuedMessage, CreateTaskOptions } from '../experimental/tasks/interfaces.js'; -import { isTerminal } from '../experimental/tasks/interfaces.js'; +import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/zod-compat.js'; +import { safeParse } from '../util/zod-compat.js'; import { getMethodLiteral, parseWithCompat } from '../util/zod-json-schema-compat.js'; import type { ResponseMessage } from './responseMessage.js'; +import type { Transport, TransportSendOptions } from './transport.js'; /** * Callback for progress notifications. diff --git a/packages/core/src/shared/responseMessage.ts b/packages/core/src/shared/responseMessage.ts index 9bed02786..8a0dcc2c2 100644 --- a/packages/core/src/shared/responseMessage.ts +++ b/packages/core/src/shared/responseMessage.ts @@ -1,4 +1,4 @@ -import type { Result, Task, McpError } from '../types/types.js'; +import type { McpError, Result, Task } from '../types/types.js'; /** * Base message type diff --git a/packages/core/src/util/inMemory.ts b/packages/core/src/util/inMemory.ts index 32af248c7..3f832b06b 100644 --- a/packages/core/src/util/inMemory.ts +++ b/packages/core/src/util/inMemory.ts @@ -1,5 +1,5 @@ import type { Transport } from '../shared/transport.js'; -import type { JSONRPCMessage, RequestId, AuthInfo } from '../types/types.js'; +import type { AuthInfo, JSONRPCMessage, RequestId } from '../types/types.js'; interface QueuedMessage { message: JSONRPCMessage; diff --git a/packages/core/src/util/zod-compat.ts b/packages/core/src/util/zod-compat.ts index 04ee5361f..b2275090a 100644 --- a/packages/core/src/util/zod-compat.ts +++ b/packages/core/src/util/zod-compat.ts @@ -4,9 +4,8 @@ // ---------------------------------------------------- import type * as z3 from 'zod/v3'; -import type * as z4 from 'zod/v4/core'; - import * as z3rt from 'zod/v3'; +import type * as z4 from 'zod/v4/core'; import * as z4mini from 'zod/v4-mini'; // --- Unified schema types --- diff --git a/packages/core/src/util/zod-json-schema-compat.ts b/packages/core/src/util/zod-json-schema-compat.ts index 8dade0dfb..cbb8b15e9 100644 --- a/packages/core/src/util/zod-json-schema-compat.ts +++ b/packages/core/src/util/zod-json-schema-compat.ts @@ -6,13 +6,12 @@ import type * as z3 from 'zod/v3'; import type * as z4c from 'zod/v4/core'; - import * as z4mini from 'zod/v4-mini'; - -import type { AnySchema, AnyObjectSchema } from './zod-compat.js'; -import { getObjectShape, safeParse, isZ4Schema, getLiteralValue } from './zod-compat.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; +import type { AnyObjectSchema, AnySchema } from './zod-compat.js'; +import { getLiteralValue, getObjectShape, isZ4Schema, safeParse } from './zod-compat.js'; + type JsonSchema = Record; // Options accepted by call sites; we map them appropriately diff --git a/packages/core/src/validation/ajv-provider.ts b/packages/core/src/validation/ajv-provider.ts index 115a98521..4a9d57214 100644 --- a/packages/core/src/validation/ajv-provider.ts +++ b/packages/core/src/validation/ajv-provider.ts @@ -4,7 +4,8 @@ import { Ajv } from 'ajv'; import _addFormats from 'ajv-formats'; -import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; function createDefaultAjvInstance(): Ajv { const ajv = new Ajv({ diff --git a/packages/core/src/validation/cfworker-provider.ts b/packages/core/src/validation/cfworker-provider.ts index 7e6329d9d..460408d62 100644 --- a/packages/core/src/validation/cfworker-provider.ts +++ b/packages/core/src/validation/cfworker-provider.ts @@ -7,7 +7,8 @@ */ import { Validator } from '@cfworker/json-schema'; -import type { JsonSchemaType, JsonSchemaValidator, JsonSchemaValidatorResult, jsonSchemaValidator } from './types.js'; + +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, JsonSchemaValidatorResult } from './types.js'; /** * JSON Schema draft version supported by @cfworker/json-schema diff --git a/packages/examples/client/src/elicitationUrlExample.ts b/packages/examples/client/src/elicitationUrlExample.ts index e9b53570b..2d235a224 100644 --- a/packages/examples/client/src/elicitationUrlExample.ts +++ b/packages/examples/client/src/elicitationUrlExample.ts @@ -5,32 +5,34 @@ // URL elicitation allows servers to prompt the end-user to open a URL in their browser // to collect sensitive information. +import { exec } from 'node:child_process'; +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; + import type { - ListToolsRequest, CallToolRequest, ElicitRequest, - ElicitResult, - ResourceLink, ElicitRequestURLParams, - OAuthClientMetadata + ElicitResult, + ListToolsRequest, + OAuthClientMetadata, + ResourceLink } from '@modelcontextprotocol/sdk-client'; import { - Client, - StreamableHTTPClientTransport, - ListToolsResultSchema, CallToolResultSchema, + Client, + ElicitationCompleteNotificationSchema, ElicitRequestSchema, - McpError, ErrorCode, - UrlElicitationRequiredError, - ElicitationCompleteNotificationSchema, getDisplayName, - UnauthorizedError + ListToolsResultSchema, + McpError, + StreamableHTTPClientTransport, + UnauthorizedError, + UrlElicitationRequiredError } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; -import { exec } from 'node:child_process'; + import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; -import { createServer } from 'node:http'; // Set up OAuth (required for this example) const OAUTH_CALLBACK_PORT = 8090; // Use different port than auth server (3001) diff --git a/packages/examples/client/src/multipleClientsParallel.ts b/packages/examples/client/src/multipleClientsParallel.ts index d6b976008..46ea7a5c0 100644 --- a/packages/examples/client/src/multipleClientsParallel.ts +++ b/packages/examples/client/src/multipleClientsParallel.ts @@ -1,9 +1,9 @@ import type { CallToolRequest, CallToolResult } from '@modelcontextprotocol/sdk-client'; import { - Client, - StreamableHTTPClientTransport, CallToolResultSchema, - LoggingMessageNotificationSchema + Client, + LoggingMessageNotificationSchema, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; /** diff --git a/packages/examples/client/src/parallelToolCallsClient.ts b/packages/examples/client/src/parallelToolCallsClient.ts index 5b4feb49d..01a42a041 100644 --- a/packages/examples/client/src/parallelToolCallsClient.ts +++ b/packages/examples/client/src/parallelToolCallsClient.ts @@ -1,10 +1,10 @@ -import type { ListToolsRequest, CallToolResult } from '@modelcontextprotocol/sdk-client'; +import type { CallToolResult, ListToolsRequest } from '@modelcontextprotocol/sdk-client'; import { + CallToolResultSchema, Client, - StreamableHTTPClientTransport, ListToolsResultSchema, - CallToolResultSchema, - LoggingMessageNotificationSchema + LoggingMessageNotificationSchema, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; /** diff --git a/packages/examples/client/src/simpleClientCredentials.ts b/packages/examples/client/src/simpleClientCredentials.ts index a1f8db1a2..6e298ea90 100644 --- a/packages/examples/client/src/simpleClientCredentials.ts +++ b/packages/examples/client/src/simpleClientCredentials.ts @@ -19,7 +19,7 @@ */ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk-client'; -import { Client, StreamableHTTPClientTransport, ClientCredentialsProvider, PrivateKeyJwtProvider } from '@modelcontextprotocol/sdk-client'; +import { Client, ClientCredentialsProvider, PrivateKeyJwtProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; diff --git a/packages/examples/client/src/simpleOAuthClient.ts b/packages/examples/client/src/simpleOAuthClient.ts index 2bd418aef..e74404fed 100644 --- a/packages/examples/client/src/simpleOAuthClient.ts +++ b/packages/examples/client/src/simpleOAuthClient.ts @@ -1,17 +1,19 @@ #!/usr/bin/env node +import { exec } from 'node:child_process'; import { createServer } from 'node:http'; import { createInterface } from 'node:readline'; import { URL } from 'node:url'; -import { exec } from 'node:child_process'; -import type { OAuthClientMetadata, CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk-client'; + +import type { CallToolRequest, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/sdk-client'; import { - Client, - StreamableHTTPClientTransport, CallToolResultSchema, + Client, ListToolsResultSchema, + StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/sdk-client'; + import { InMemoryOAuthClientProvider } from './simpleOAuthClientProvider.js'; // Configuration diff --git a/packages/examples/client/src/simpleOAuthClientProvider.ts b/packages/examples/client/src/simpleOAuthClientProvider.ts index 6917729f6..8c21c14ca 100644 --- a/packages/examples/client/src/simpleOAuthClientProvider.ts +++ b/packages/examples/client/src/simpleOAuthClientProvider.ts @@ -1,4 +1,4 @@ -import type { OAuthClientProvider, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk-client'; +import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthClientProvider, OAuthTokens } from '@modelcontextprotocol/sdk-client'; /** * In-memory OAuth client provider for demonstration purposes diff --git a/packages/examples/client/src/simpleStreamableHttp.ts b/packages/examples/client/src/simpleStreamableHttp.ts index 62c67d21f..a3cc7deb0 100644 --- a/packages/examples/client/src/simpleStreamableHttp.ts +++ b/packages/examples/client/src/simpleStreamableHttp.ts @@ -1,30 +1,31 @@ +import { createInterface } from 'node:readline'; + import type { - ListToolsRequest, CallToolRequest, - ListPromptsRequest, GetPromptRequest, + ListPromptsRequest, ListResourcesRequest, - ResourceLink, - ReadResourceRequest + ListToolsRequest, + ReadResourceRequest, + ResourceLink } from '@modelcontextprotocol/sdk-client'; import { - Client, - StreamableHTTPClientTransport, - ListToolsResultSchema, CallToolResultSchema, - ListPromptsResultSchema, + Client, + ElicitRequestSchema, + ErrorCode, + getDisplayName, GetPromptResultSchema, + ListPromptsResultSchema, ListResourcesResultSchema, + ListToolsResultSchema, LoggingMessageNotificationSchema, - ResourceListChangedNotificationSchema, - ElicitRequestSchema, + McpError, ReadResourceResultSchema, RELATED_TASK_META_KEY, - ErrorCode, - McpError, - getDisplayName + ResourceListChangedNotificationSchema, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; import { Ajv } from 'ajv'; // Create readline interface for user input diff --git a/packages/examples/client/src/simpleTaskInteractiveClient.ts b/packages/examples/client/src/simpleTaskInteractiveClient.ts index 09f2c8b19..dacbc21e5 100644 --- a/packages/examples/client/src/simpleTaskInteractiveClient.ts +++ b/packages/examples/client/src/simpleTaskInteractiveClient.ts @@ -7,17 +7,18 @@ * - Using task-based tool execution with streaming */ -import type { TextContent, CreateMessageRequest, CreateMessageResult } from '@modelcontextprotocol/sdk-client'; +import { createInterface } from 'node:readline'; + +import type { CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/sdk-client'; import { - Client, - StreamableHTTPClientTransport, CallToolResultSchema, - ElicitRequestSchema, + Client, CreateMessageRequestSchema, + ElicitRequestSchema, ErrorCode, - McpError + McpError, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; -import { createInterface } from 'node:readline'; // Create readline interface for user input const readline = createInterface({ diff --git a/packages/examples/client/src/ssePollingClient.ts b/packages/examples/client/src/ssePollingClient.ts index fa16783a8..c98477936 100644 --- a/packages/examples/client/src/ssePollingClient.ts +++ b/packages/examples/client/src/ssePollingClient.ts @@ -13,10 +13,10 @@ * Requires: ssePollingExample.ts server running on port 3001 */ import { - Client, - StreamableHTTPClientTransport, CallToolResultSchema, - LoggingMessageNotificationSchema + Client, + LoggingMessageNotificationSchema, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; const SERVER_URL = 'http://localhost:3001/mcp'; diff --git a/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts b/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts index 31a45e856..656b8ec98 100644 --- a/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts +++ b/packages/examples/client/src/streamableHttpWithSseFallbackClient.ts @@ -1,11 +1,11 @@ -import type { ListToolsRequest, CallToolRequest } from '@modelcontextprotocol/sdk-client'; +import type { CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk-client'; import { + CallToolResultSchema, Client, - StreamableHTTPClientTransport, - SSEClientTransport, ListToolsResultSchema, - CallToolResultSchema, - LoggingMessageNotificationSchema + LoggingMessageNotificationSchema, + SSEClientTransport, + StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; /** diff --git a/packages/examples/server/src/elicitationFormExample.ts b/packages/examples/server/src/elicitationFormExample.ts index 51fdd27ac..81a286e8e 100644 --- a/packages/examples/server/src/elicitationFormExample.ts +++ b/packages/examples/server/src/elicitationFormExample.ts @@ -8,8 +8,9 @@ // to collect *sensitive* user input via a browser. import { randomUUID } from 'node:crypto'; + +import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import { type Request, type Response } from 'express'; -import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed diff --git a/packages/examples/server/src/elicitationUrlExample.ts b/packages/examples/server/src/elicitationUrlExample.ts index 2ecabc685..2f5b5da76 100644 --- a/packages/examples/server/src/elicitationUrlExample.ts +++ b/packages/examples/server/src/elicitationUrlExample.ts @@ -7,26 +7,27 @@ // 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 type { Request, Response } from 'express'; -import express from 'express'; import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; + +import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; import type { CallToolResult, ElicitRequestURLParams, ElicitResult, OAuthMetadata } from '@modelcontextprotocol/sdk-server'; import { - McpServer, + checkResourceAllowed, createMcpExpressApp, - StreamableHTTPServerTransport, getOAuthProtectedResourceMetadataUrl, + isInitializeRequest, mcpAuthMetadataRouter, + McpServer, requireBearerAuth, - UrlElicitationRequiredError, - isInitializeRequest, - checkResourceAllowed + StreamableHTTPServerTransport, + UrlElicitationRequiredError } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; - import cors from 'cors'; +import type { Request, Response } from 'express'; +import express from 'express'; +import { z } from 'zod'; + +import { InMemoryEventStore } from './inMemoryEventStore.js'; // Create an MCP server with implementation details const getServer = () => { diff --git a/packages/examples/server/src/inMemoryEventStore.ts b/packages/examples/server/src/inMemoryEventStore.ts index 4e93e1a8f..7af31c6f8 100644 --- a/packages/examples/server/src/inMemoryEventStore.ts +++ b/packages/examples/server/src/inMemoryEventStore.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, EventStore } from '@modelcontextprotocol/sdk-server'; +import type { EventStore, JSONRPCMessage } from '@modelcontextprotocol/sdk-server'; /** * Simple in-memory implementation of the EventStore interface for resumability diff --git a/packages/examples/server/src/jsonResponseStreamableHttp.ts b/packages/examples/server/src/jsonResponseStreamableHttp.ts index c33308729..994f4fa05 100644 --- a/packages/examples/server/src/jsonResponseStreamableHttp.ts +++ b/packages/examples/server/src/jsonResponseStreamableHttp.ts @@ -1,7 +1,8 @@ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; + import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; -import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; import * as z from 'zod/v4'; // Create an MCP server with implementation details diff --git a/packages/examples/server/src/simpleSseServer.ts b/packages/examples/server/src/simpleSseServer.ts index 7e41c1f71..ae381985c 100644 --- a/packages/examples/server/src/simpleSseServer.ts +++ b/packages/examples/server/src/simpleSseServer.ts @@ -1,6 +1,6 @@ -import type { Request, Response } from 'express'; import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; -import { McpServer, SSEServerTransport, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp, McpServer, SSEServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; import * as z from 'zod/v4'; /** diff --git a/packages/examples/server/src/simpleStatelessStreamableHttp.ts b/packages/examples/server/src/simpleStatelessStreamableHttp.ts index 34668b27c..1cec427fd 100644 --- a/packages/examples/server/src/simpleStatelessStreamableHttp.ts +++ b/packages/examples/server/src/simpleStatelessStreamableHttp.ts @@ -1,6 +1,6 @@ -import type { Request, Response } from 'express'; import type { CallToolResult, GetPromptResult, ReadResourceResult } from '@modelcontextprotocol/sdk-server'; -import { McpServer, StreamableHTTPServerTransport, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; import * as z from 'zod/v4'; const getServer = () => { diff --git a/packages/examples/server/src/simpleStreamableHttp.ts b/packages/examples/server/src/simpleStreamableHttp.ts index fc9fd4702..72382501c 100644 --- a/packages/examples/server/src/simpleStreamableHttp.ts +++ b/packages/examples/server/src/simpleStreamableHttp.ts @@ -1,29 +1,31 @@ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import * as z from 'zod/v4'; + +import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; import type { CallToolResult, GetPromptResult, + OAuthMetadata, PrimitiveSchemaDefinition, ReadResourceResult, - ResourceLink, - OAuthMetadata + ResourceLink } from '@modelcontextprotocol/sdk-server'; import { - McpServer, - StreamableHTTPServerTransport, - getOAuthProtectedResourceMetadataUrl, - mcpAuthMetadataRouter, - requireBearerAuth, + checkResourceAllowed, createMcpExpressApp, ElicitResultSchema, - isInitializeRequest, - InMemoryTaskStore, + getOAuthProtectedResourceMetadataUrl, InMemoryTaskMessageQueue, - checkResourceAllowed + InMemoryTaskStore, + isInitializeRequest, + mcpAuthMetadataRouter, + McpServer, + requireBearerAuth, + StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; +import * as z from 'zod/v4'; + import { InMemoryEventStore } from './inMemoryEventStore.js'; -import { setupAuthServer } from '@modelcontextprotocol/sdk-examples-shared'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); diff --git a/packages/examples/server/src/simpleTaskInteractive.ts b/packages/examples/server/src/simpleTaskInteractive.ts index b60349324..ff1105fac 100644 --- a/packages/examples/server/src/simpleTaskInteractive.ts +++ b/packages/examples/server/src/simpleTaskInteractive.ts @@ -9,42 +9,43 @@ * creates a task, and the result is fetched via tasks/result endpoint. */ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; + import type { CallToolResult, + CreateMessageRequest, + CreateMessageResult, + CreateTaskOptions, CreateTaskResult, - GetTaskResult, - Tool, - TextContent, - Task, - Result, - RequestId, - JSONRPCRequest, - SamplingMessage, ElicitRequestFormParams, - CreateMessageRequest, ElicitResult, - CreateMessageResult, - PrimitiveSchemaDefinition, GetTaskPayloadResult, - TaskMessageQueue, + GetTaskResult, + JSONRPCRequest, + PrimitiveSchemaDefinition, QueuedMessage, QueuedRequest, - CreateTaskOptions + RequestId, + Result, + SamplingMessage, + Task, + TaskMessageQueue, + TextContent, + Tool } from '@modelcontextprotocol/sdk-server'; import { - Server, - createMcpExpressApp, - StreamableHTTPServerTransport, - RELATED_TASK_META_KEY, - ListToolsRequestSchema, CallToolRequestSchema, - GetTaskRequestSchema, + createMcpExpressApp, GetTaskPayloadRequestSchema, + GetTaskRequestSchema, + InMemoryTaskStore, isTerminal, - InMemoryTaskStore + ListToolsRequestSchema, + RELATED_TASK_META_KEY, + Server, + StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; // ============================================================================ // Resolver - Promise-like for passing results between async operations diff --git a/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts index 4dc4b197f..7943faba3 100644 --- a/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts +++ b/packages/examples/server/src/sseAndStreamableHttpCompatibleServer.ts @@ -1,14 +1,16 @@ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; + import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; import { + createMcpExpressApp, + isInitializeRequest, McpServer, - StreamableHTTPServerTransport, SSEServerTransport, - isInitializeRequest, - createMcpExpressApp + StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; import * as z from 'zod/v4'; + import { InMemoryEventStore } from './inMemoryEventStore.js'; /** diff --git a/packages/examples/server/src/ssePollingExample.ts b/packages/examples/server/src/ssePollingExample.ts index bf10b78dd..0c7e02a78 100644 --- a/packages/examples/server/src/ssePollingExample.ts +++ b/packages/examples/server/src/ssePollingExample.ts @@ -12,12 +12,14 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; + import type { CallToolResult } from '@modelcontextprotocol/sdk-server'; -import { McpServer, createMcpExpressApp, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { InMemoryEventStore } from './inMemoryEventStore.js'; +import { createMcpExpressApp, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; import cors from 'cors'; +import type { Request, Response } from 'express'; + +import { InMemoryEventStore } from './inMemoryEventStore.js'; // Create the MCP server const server = new McpServer( diff --git a/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts b/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts index 806ab5650..0f3f7bcb1 100644 --- a/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts +++ b/packages/examples/server/src/standaloneSseWithGetStreamableHttp.ts @@ -1,7 +1,8 @@ -import type { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; + import type { ReadResourceResult } from '@modelcontextprotocol/sdk-server'; -import { McpServer, StreamableHTTPServerTransport, isInitializeRequest, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp, isInitializeRequest, McpServer, StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; +import type { Request, Response } from 'express'; // Create an MCP server with implementation details const server = new McpServer({ diff --git a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts index 0087019c1..e4f94dc28 100644 --- a/packages/examples/shared/src/demoInMemoryOAuthProvider.ts +++ b/packages/examples/shared/src/demoInMemoryOAuthProvider.ts @@ -1,14 +1,15 @@ import { randomUUID } from 'node:crypto'; + import type { + AuthInfo, AuthorizationParams, - OAuthServerProvider, - OAuthRegisteredClientsStore, OAuthClientInformationFull, OAuthMetadata, - OAuthTokens, - AuthInfo + OAuthRegisteredClientsStore, + OAuthServerProvider, + OAuthTokens } from '@modelcontextprotocol/sdk-server'; -import { createOAuthMetadata, mcpAuthRouter, resourceUrlFromServerUrl, InvalidRequestError } from '@modelcontextprotocol/sdk-server'; +import { createOAuthMetadata, InvalidRequestError, mcpAuthRouter, resourceUrlFromServerUrl } from '@modelcontextprotocol/sdk-server'; import type { Request, Response } from 'express'; import express from 'express'; diff --git a/packages/integration/test/__fixtures__/serverThatHangs.ts b/packages/integration/test/__fixtures__/serverThatHangs.ts index db3ebce53..2780ca557 100644 --- a/packages/integration/test/__fixtures__/serverThatHangs.ts +++ b/packages/integration/test/__fixtures__/serverThatHangs.ts @@ -1,5 +1,6 @@ -import { setInterval } from 'node:timers'; import process from 'node:process'; +import { setInterval } from 'node:timers'; + import { McpServer, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; const transport = new StdioServerTransport(); diff --git a/packages/integration/test/client/client.test.ts b/packages/integration/test/client/client.test.ts index 6b872c8d5..d0762ce12 100644 --- a/packages/integration/test/client/client.test.ts +++ b/packages/integration/test/client/client.test.ts @@ -2,30 +2,30 @@ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/sdk-client'; -import type { Tool, Prompt, Resource, Transport } from '@modelcontextprotocol/sdk-core'; +import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/sdk-core'; import { - RequestSchema, - NotificationSchema, - ResultSchema, - LATEST_PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, - InitializeRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - ListToolsResultSchema, - ListPromptsRequestSchema, CallToolRequestSchema, CallToolResultSchema, CreateMessageRequestSchema, + CreateTaskResultSchema, ElicitRequestSchema, ElicitResultSchema, - ListRootsRequestSchema, ErrorCode, + InitializeRequestSchema, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListRootsRequestSchema, + ListToolsRequestSchema, + ListToolsResultSchema, McpError, - CreateTaskResultSchema, - InMemoryTransport + NotificationSchema, + RequestSchema, + ResultSchema, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk-core'; -import { Server, McpServer, InMemoryTaskStore } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/sdk-server'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; diff --git a/packages/integration/test/experimental/tasks/task-listing.test.ts b/packages/integration/test/experimental/tasks/task-listing.test.ts index 49518459d..ae541c6f8 100644 --- a/packages/integration/test/experimental/tasks/task-listing.test.ts +++ b/packages/integration/test/experimental/tasks/task-listing.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ErrorCode, McpError } from '@modelcontextprotocol/sdk-core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js'; describe('Task Listing with Pagination', () => { diff --git a/packages/integration/test/experimental/tasks/task.test.ts b/packages/integration/test/experimental/tasks/task.test.ts index 939adcd38..293f61c9f 100644 --- a/packages/integration/test/experimental/tasks/task.test.ts +++ b/packages/integration/test/experimental/tasks/task.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest'; import { isTerminal } from '@modelcontextprotocol/sdk-core'; import type { Task } from '@modelcontextprotocol/sdk-server'; +import { describe, expect, it } from 'vitest'; describe('Task utility functions', () => { describe('isTerminal', () => { diff --git a/packages/integration/test/helpers/http.ts b/packages/integration/test/helpers/http.ts index c4774be89..7d165da83 100644 --- a/packages/integration/test/helpers/http.ts +++ b/packages/integration/test/helpers/http.ts @@ -1,7 +1,8 @@ import type http from 'node:http'; import { type Server } from 'node:http'; -import type { Response } from 'express'; import type { AddressInfo } from 'node:net'; + +import type { Response } from 'express'; import { vi } from 'vitest'; /** diff --git a/packages/integration/test/helpers/mcp.ts b/packages/integration/test/helpers/mcp.ts index 6795cb65a..5b49f2bae 100644 --- a/packages/integration/test/helpers/mcp.ts +++ b/packages/integration/test/helpers/mcp.ts @@ -1,7 +1,7 @@ -import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; import { Client } from '@modelcontextprotocol/sdk-client'; -import { Server, InMemoryTaskStore, InMemoryTaskMessageQueue } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/sdk-server'; +import { InMemoryTaskMessageQueue, InMemoryTaskStore, Server } from '@modelcontextprotocol/sdk-server'; export interface InMemoryTaskEnvironment { client: Client; diff --git a/packages/integration/test/processCleanup.test.ts b/packages/integration/test/processCleanup.test.ts index 484767b77..5c8fd97e1 100644 --- a/packages/integration/test/processCleanup.test.ts +++ b/packages/integration/test/processCleanup.test.ts @@ -1,7 +1,8 @@ import path from 'node:path'; import { Readable, Writable } from 'node:stream'; + import { Client, StdioClientTransport } from '@modelcontextprotocol/sdk-client'; -import { Server, StdioServerTransport, LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk-server'; +import { LoggingMessageNotificationSchema, Server, StdioServerTransport } from '@modelcontextprotocol/sdk-server'; // Use the local fixtures directory alongside this test file const FIXTURES_DIR = path.resolve(__dirname, './__fixtures__'); diff --git a/packages/integration/test/server.test.ts b/packages/integration/test/server.test.ts index f833bfe21..af07a4b99 100644 --- a/packages/integration/test/server.test.ts +++ b/packages/integration/test/server.test.ts @@ -1,24 +1,24 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import supertest from 'supertest'; import { Client } from '@modelcontextprotocol/sdk-client'; import type { - Transport, - LoggingMessageNotification, + AnyObjectSchema, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, - AnyObjectSchema + LoggingMessageNotification, + Transport } from '@modelcontextprotocol/sdk-core'; import { - InMemoryTransport, CallToolRequestSchema, CallToolResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, + CreateTaskResultSchema, + ElicitationCompleteNotificationSchema, ElicitRequestSchema, ElicitResultSchema, - ElicitationCompleteNotificationSchema, ErrorCode, + InMemoryTransport, LATEST_PROTOCOL_VERSION, ListPromptsRequestSchema, ListResourcesRequestSchema, @@ -28,10 +28,10 @@ import { RequestSchema, ResultSchema, SetLevelRequestSchema, - SUPPORTED_PROTOCOL_VERSIONS, - CreateTaskResultSchema + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk-core'; -import { Server, McpServer, InMemoryTaskStore, InMemoryTaskMessageQueue, createMcpExpressApp } from '@modelcontextprotocol/sdk-server'; +import { createMcpExpressApp, InMemoryTaskMessageQueue, InMemoryTaskStore, McpServer, Server } from '@modelcontextprotocol/sdk-server'; +import supertest from 'supertest'; import * as z3 from 'zod/v3'; import * as z4 from 'zod/v4'; diff --git a/packages/integration/test/server/elicitation.test.ts b/packages/integration/test/server/elicitation.test.ts index 33e5ce5b0..d43880a4c 100644 --- a/packages/integration/test/server/elicitation.test.ts +++ b/packages/integration/test/server/elicitation.test.ts @@ -10,10 +10,10 @@ import { Client } from '@modelcontextprotocol/sdk-client'; import type { ElicitRequestFormParams } from '@modelcontextprotocol/sdk-core'; import { - InMemoryTransport, - ElicitRequestSchema, AjvJsonSchemaValidator, - CfWorkerJsonSchemaValidator + CfWorkerJsonSchemaValidator, + ElicitRequestSchema, + InMemoryTransport } from '@modelcontextprotocol/sdk-core'; import { Server } from '@modelcontextprotocol/sdk-server'; diff --git a/packages/integration/test/server/mcp.test.ts b/packages/integration/test/server/mcp.test.ts index 9dfad8689..f6597bd09 100644 --- a/packages/integration/test/server/mcp.test.ts +++ b/packages/integration/test/server/mcp.test.ts @@ -1,10 +1,11 @@ import { Client } from '@modelcontextprotocol/sdk-client'; -import { InMemoryTransport, getDisplayName, UriTemplate, InMemoryTaskStore } from '@modelcontextprotocol/sdk-core'; +import { getDisplayName, InMemoryTaskStore, InMemoryTransport, UriTemplate } from '@modelcontextprotocol/sdk-core'; import { - CallToolResultSchema, type CallToolResult, + CallToolResultSchema, CompleteResultSchema, ElicitRequestSchema, + ErrorCode, GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, @@ -14,12 +15,12 @@ import { type Notification, ReadResourceResultSchema, type TextContent, - UrlElicitationRequiredError, - ErrorCode + UrlElicitationRequiredError } from '@modelcontextprotocol/sdk-core'; + import { completable } from '../../../server/src/server/completable.js'; import { McpServer, ResourceTemplate } from '../../../server/src/server/mcp.js'; -import { zodTestMatrix, type ZodMatrixEntry } from '../../../server/test/server/__fixtures__/zodTestMatrix.js'; +import { type ZodMatrixEntry, zodTestMatrix } from '../../../server/test/server/__fixtures__/zodTestMatrix.js'; function createLatch() { let latch = false; diff --git a/packages/integration/test/stateManagementStreamableHttp.test.ts b/packages/integration/test/stateManagementStreamableHttp.test.ts index 877685550..3fdf40391 100644 --- a/packages/integration/test/stateManagementStreamableHttp.test.ts +++ b/packages/integration/test/stateManagementStreamableHttp.test.ts @@ -1,16 +1,18 @@ -import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; + import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { - McpServer, - StreamableHTTPServerTransport, CallToolResultSchema, - ListToolsResultSchema, - ListResourcesResultSchema, + LATEST_PROTOCOL_VERSION, ListPromptsResultSchema, - LATEST_PROTOCOL_VERSION + ListResourcesResultSchema, + ListToolsResultSchema, + McpServer, + StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; + +import { type ZodMatrixEntry, zodTestMatrix } from './__fixtures__/zodTestMatrix.js'; import { listenOnRandomPort } from './helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { diff --git a/packages/integration/test/taskLifecycle.test.ts b/packages/integration/test/taskLifecycle.test.ts index f00f4cd9e..d7e3dc8ad 100644 --- a/packages/integration/test/taskLifecycle.test.ts +++ b/packages/integration/test/taskLifecycle.test.ts @@ -1,22 +1,24 @@ -import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; + import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; +import type { TaskRequestOptions } from '@modelcontextprotocol/sdk-server'; import { - McpServer, - StreamableHTTPServerTransport, CallToolResultSchema, CreateTaskResultSchema, ElicitRequestSchema, ElicitResultSchema, ErrorCode, + InMemoryTaskMessageQueue, + InMemoryTaskStore, McpError, + McpServer, RELATED_TASK_META_KEY, - TaskSchema, - InMemoryTaskStore, - InMemoryTaskMessageQueue + StreamableHTTPServerTransport, + TaskSchema } from '@modelcontextprotocol/sdk-server'; import { z } from 'zod'; -import type { TaskRequestOptions } from '@modelcontextprotocol/sdk-server'; + import { listenOnRandomPort } from './helpers/http.js'; import { waitForTaskStatus } from './helpers/tasks.js'; diff --git a/packages/integration/test/taskResumability.test.ts b/packages/integration/test/taskResumability.test.ts index 7ba03a026..560c157d0 100644 --- a/packages/integration/test/taskResumability.test.ts +++ b/packages/integration/test/taskResumability.test.ts @@ -1,14 +1,16 @@ -import { createServer, type Server } from 'node:http'; import { randomUUID } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; + import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk-client'; import { - McpServer, - StreamableHTTPServerTransport, CallToolResultSchema, + InMemoryEventStore, LoggingMessageNotificationSchema, - InMemoryEventStore + McpServer, + StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk-server'; -import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; + +import { type ZodMatrixEntry, zodTestMatrix } from './__fixtures__/zodTestMatrix.js'; import { listenOnRandomPort } from './helpers/http.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { diff --git a/packages/integration/test/title.test.ts b/packages/integration/test/title.test.ts index f44d8c7ed..d4d5f40ac 100644 --- a/packages/integration/test/title.test.ts +++ b/packages/integration/test/title.test.ts @@ -1,7 +1,8 @@ -import { Server, McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk-server'; import { Client } from '@modelcontextprotocol/sdk-client'; import { InMemoryTransport } from '@modelcontextprotocol/sdk-core'; -import { zodTestMatrix, type ZodMatrixEntry } from './__fixtures__/zodTestMatrix.js'; +import { McpServer, ResourceTemplate, Server } from '@modelcontextprotocol/sdk-server'; + +import { type ZodMatrixEntry, zodTestMatrix } from './__fixtures__/zodTestMatrix.js'; describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { const { z } = entry; diff --git a/packages/server/src/experimental/tasks/index.ts b/packages/server/src/experimental/tasks/index.ts index 4eb097403..51ebd7fec 100644 --- a/packages/server/src/experimental/tasks/index.ts +++ b/packages/server/src/experimental/tasks/index.ts @@ -9,5 +9,5 @@ export * from './interfaces.js'; // Wrapper classes -export * from './server.js'; export * from './mcp-server.js'; +export * from './server.js'; diff --git a/packages/server/src/experimental/tasks/interfaces.ts b/packages/server/src/experimental/tasks/interfaces.ts index 21eba4296..cae24bcb4 100644 --- a/packages/server/src/experimental/tasks/interfaces.ts +++ b/packages/server/src/experimental/tasks/interfaces.ts @@ -4,15 +4,16 @@ */ import type { - Result, + AnySchema, CallToolResult, - GetTaskResult, - CreateTaskResult, CreateTaskRequestHandlerExtra, + CreateTaskResult, + GetTaskResult, + Result, TaskRequestHandlerExtra, - ZodRawShapeCompat, - AnySchema + ZodRawShapeCompat } from '@modelcontextprotocol/sdk-core'; + import type { BaseToolCallback } from '../../server/mcp.js'; // ============================================================================ diff --git a/packages/server/src/experimental/tasks/mcp-server.ts b/packages/server/src/experimental/tasks/mcp-server.ts index 7b6caf794..29bce908f 100644 --- a/packages/server/src/experimental/tasks/mcp-server.ts +++ b/packages/server/src/experimental/tasks/mcp-server.ts @@ -5,8 +5,9 @@ * @experimental */ -import type { McpServer, RegisteredTool, AnyToolHandler } from '../../server/mcp.js'; -import type { ZodRawShapeCompat, AnySchema, ToolAnnotations, ToolExecution, TaskToolExecution } from '@modelcontextprotocol/sdk-core'; +import type { AnySchema, TaskToolExecution, ToolAnnotations, ToolExecution, ZodRawShapeCompat } from '@modelcontextprotocol/sdk-core'; + +import type { AnyToolHandler, McpServer, RegisteredTool } from '../../server/mcp.js'; import type { ToolTaskHandler } from './interfaces.js'; /** diff --git a/packages/server/src/experimental/tasks/server.ts b/packages/server/src/experimental/tasks/server.ts index 91ce6002b..c521e38dc 100644 --- a/packages/server/src/experimental/tasks/server.ts +++ b/packages/server/src/experimental/tasks/server.ts @@ -5,21 +5,22 @@ * @experimental */ -import type { Server } from '../../server/server.js'; import type { - RequestOptions, - ResponseMessage, AnySchema, - SchemaOutput, - ServerRequest, + CancelTaskResult, + GetTaskResult, + ListTasksResult, Notification, Request, + RequestOptions, + ResponseMessage, Result, - GetTaskResult, - ListTasksResult, - CancelTaskResult + SchemaOutput, + ServerRequest } from '@modelcontextprotocol/sdk-core'; +import type { Server } from '../../server/server.js'; + /** * Experimental task features for low-level MCP servers. * diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 57f90f2cd..b98402c6c 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,11 +1,11 @@ export * from './server/completable.js'; export * from './server/express.js'; +export * from './server/inMemoryEventStore.js'; export * from './server/mcp.js'; export * from './server/server.js'; export * from './server/sse.js'; export * from './server/stdio.js'; export * from './server/streamableHttp.js'; -export * from './server/inMemoryEventStore.js'; // auth exports export * from './server/auth/index.js'; diff --git a/packages/server/src/server/auth/handlers/authorize.ts b/packages/server/src/server/auth/handlers/authorize.ts index bc3ed1a4f..32d96d017 100644 --- a/packages/server/src/server/auth/handlers/authorize.ts +++ b/packages/server/src/server/auth/handlers/authorize.ts @@ -1,11 +1,12 @@ +import { InvalidClientError, InvalidRequestError, OAuthError, ServerError, TooManyRequestsError } from '@modelcontextprotocol/sdk-core'; import type { RequestHandler } from 'express'; -import * as z from 'zod/v4'; import express from 'express'; -import type { OAuthServerProvider } from '../provider.js'; import type { Options as RateLimitOptions } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit'; +import * as z from 'zod/v4'; + import { allowedMethods } from '../middleware/allowedMethods.js'; -import { InvalidRequestError, InvalidClientError, ServerError, TooManyRequestsError, OAuthError } from '@modelcontextprotocol/sdk-core'; +import type { OAuthServerProvider } from '../provider.js'; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/metadata.ts b/packages/server/src/server/auth/handlers/metadata.ts index f521881a1..7583ce624 100644 --- a/packages/server/src/server/auth/handlers/metadata.ts +++ b/packages/server/src/server/auth/handlers/metadata.ts @@ -1,7 +1,8 @@ -import type { RequestHandler } from 'express'; -import express from 'express'; import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk-core'; import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; + import { allowedMethods } from '../middleware/allowedMethods.js'; export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { diff --git a/packages/server/src/server/auth/handlers/register.ts b/packages/server/src/server/auth/handlers/register.ts index 3527ebc98..af7570997 100644 --- a/packages/server/src/server/auth/handlers/register.ts +++ b/packages/server/src/server/auth/handlers/register.ts @@ -1,18 +1,20 @@ -import type { RequestHandler } from 'express'; -import express from 'express'; +import crypto from 'node:crypto'; + import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; import { - OAuthClientMetadataSchema, InvalidClientMetadataError, + OAuthClientMetadataSchema, + OAuthError, ServerError, - TooManyRequestsError, - OAuthError + TooManyRequestsError } from '@modelcontextprotocol/sdk-core'; -import crypto from 'node:crypto'; import cors from 'cors'; -import type { OAuthRegisteredClientsStore } from '../clients.js'; +import type { RequestHandler } from 'express'; +import express from 'express'; import type { Options as RateLimitOptions } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit'; + +import type { OAuthRegisteredClientsStore } from '../clients.js'; import { allowedMethods } from '../middleware/allowedMethods.js'; export type ClientRegistrationHandlerOptions = { diff --git a/packages/server/src/server/auth/handlers/revoke.ts b/packages/server/src/server/auth/handlers/revoke.ts index d7e889495..a32d817a9 100644 --- a/packages/server/src/server/auth/handlers/revoke.ts +++ b/packages/server/src/server/auth/handlers/revoke.ts @@ -1,18 +1,19 @@ -import type { OAuthServerProvider } from '../provider.js'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import cors from 'cors'; -import { authenticateClient } from '../middleware/clientAuth.js'; import { - OAuthTokenRevocationRequestSchema, InvalidRequestError, + OAuthError, + OAuthTokenRevocationRequestSchema, ServerError, - TooManyRequestsError, - OAuthError + TooManyRequestsError } from '@modelcontextprotocol/sdk-core'; +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; import type { Options as RateLimitOptions } from 'express-rate-limit'; import { rateLimit } from 'express-rate-limit'; + import { allowedMethods } from '../middleware/allowedMethods.js'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import type { OAuthServerProvider } from '../provider.js'; export type RevocationHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/handlers/token.ts b/packages/server/src/server/auth/handlers/token.ts index afccdc2b8..6a86de283 100644 --- a/packages/server/src/server/auth/handlers/token.ts +++ b/packages/server/src/server/auth/handlers/token.ts @@ -1,21 +1,22 @@ -import * as z from 'zod/v4'; -import type { RequestHandler } from 'express'; -import express from 'express'; -import type { OAuthServerProvider } from '../provider.js'; -import cors from 'cors'; -import { verifyChallenge } from 'pkce-challenge'; -import { authenticateClient } from '../middleware/clientAuth.js'; -import type { Options as RateLimitOptions } from 'express-rate-limit'; -import { rateLimit } from 'express-rate-limit'; -import { allowedMethods } from '../middleware/allowedMethods.js'; import { - InvalidRequestError, InvalidGrantError, - UnsupportedGrantTypeError, + InvalidRequestError, + OAuthError, ServerError, TooManyRequestsError, - OAuthError + UnsupportedGrantTypeError } from '@modelcontextprotocol/sdk-core'; +import cors from 'cors'; +import type { RequestHandler } from 'express'; +import express from 'express'; +import type { Options as RateLimitOptions } from 'express-rate-limit'; +import { rateLimit } from 'express-rate-limit'; +import { verifyChallenge } from 'pkce-challenge'; +import * as z from 'zod/v4'; + +import { allowedMethods } from '../middleware/allowedMethods.js'; +import { authenticateClient } from '../middleware/clientAuth.js'; +import type { OAuthServerProvider } from '../provider.js'; export type TokenHandlerOptions = { provider: OAuthServerProvider; diff --git a/packages/server/src/server/auth/index.ts b/packages/server/src/server/auth/index.ts index 10c9d7ace..5369224cf 100644 --- a/packages/server/src/server/auth/index.ts +++ b/packages/server/src/server/auth/index.ts @@ -1,15 +1,12 @@ export * from './clients.js'; -export * from './provider.js'; -export * from './router.js'; - export * from './handlers/authorize.js'; export * from './handlers/metadata.js'; export * from './handlers/register.js'; export * from './handlers/revoke.js'; export * from './handlers/token.js'; - export * from './middleware/allowedMethods.js'; export * from './middleware/bearerAuth.js'; export * from './middleware/clientAuth.js'; - +export * from './provider.js'; export * from './providers/proxyProvider.js'; +export * from './router.js'; diff --git a/packages/server/src/server/auth/middleware/allowedMethods.ts b/packages/server/src/server/auth/middleware/allowedMethods.ts index 1cb16768f..54e1671e4 100644 --- a/packages/server/src/server/auth/middleware/allowedMethods.ts +++ b/packages/server/src/server/auth/middleware/allowedMethods.ts @@ -1,5 +1,5 @@ -import type { RequestHandler } from 'express'; import { MethodNotAllowedError } from '@modelcontextprotocol/sdk-core'; +import type { RequestHandler } from 'express'; /** * Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response. diff --git a/packages/server/src/server/auth/middleware/bearerAuth.ts b/packages/server/src/server/auth/middleware/bearerAuth.ts index fad637abb..97abcccaf 100644 --- a/packages/server/src/server/auth/middleware/bearerAuth.ts +++ b/packages/server/src/server/auth/middleware/bearerAuth.ts @@ -1,6 +1,7 @@ -import type { RequestHandler } from 'express'; import type { AuthInfo } from '@modelcontextprotocol/sdk-core'; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from '@modelcontextprotocol/sdk-core'; +import type { RequestHandler } from 'express'; + import type { OAuthTokenVerifier } from '../provider.js'; export type BearerAuthMiddlewareOptions = { diff --git a/packages/server/src/server/auth/middleware/clientAuth.ts b/packages/server/src/server/auth/middleware/clientAuth.ts index b37d9d7cd..6e4a09e24 100644 --- a/packages/server/src/server/auth/middleware/clientAuth.ts +++ b/packages/server/src/server/auth/middleware/clientAuth.ts @@ -1,8 +1,9 @@ -import * as z from 'zod/v4'; +import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; +import { InvalidClientError, InvalidRequestError, OAuthError, ServerError } from '@modelcontextprotocol/sdk-core'; import type { RequestHandler } from 'express'; +import * as z from 'zod/v4'; + import type { OAuthRegisteredClientsStore } from '../clients.js'; -import type { OAuthClientInformationFull } from '@modelcontextprotocol/sdk-core'; -import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '@modelcontextprotocol/sdk-core'; export type ClientAuthenticationMiddlewareOptions = { /** diff --git a/packages/server/src/server/auth/provider.ts b/packages/server/src/server/auth/provider.ts index 8b5db3ae6..3cde1c5e1 100644 --- a/packages/server/src/server/auth/provider.ts +++ b/packages/server/src/server/auth/provider.ts @@ -1,6 +1,7 @@ +import type { AuthInfo, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk-core'; import type { Response } from 'express'; + import type { OAuthRegisteredClientsStore } from './clients.js'; -import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens, AuthInfo } from '@modelcontextprotocol/sdk-core'; export type AuthorizationParams = { state?: string; diff --git a/packages/server/src/server/auth/providers/proxyProvider.ts b/packages/server/src/server/auth/providers/proxyProvider.ts index 3eb052905..b2f22a451 100644 --- a/packages/server/src/server/auth/providers/proxyProvider.ts +++ b/packages/server/src/server/auth/providers/proxyProvider.ts @@ -1,13 +1,14 @@ -import type { Response } from 'express'; -import type { OAuthRegisteredClientsStore } from '../clients.js'; import type { + AuthInfo, + FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, - OAuthTokens, - AuthInfo, - FetchLike + OAuthTokens } from '@modelcontextprotocol/sdk-core'; import { OAuthClientInformationFullSchema, OAuthTokensSchema, ServerError } from '@modelcontextprotocol/sdk-core'; +import type { Response } from 'express'; + +import type { OAuthRegisteredClientsStore } from '../clients.js'; import type { AuthorizationParams, OAuthServerProvider } from '../provider.js'; export type ProxyEndpoints = { diff --git a/packages/server/src/server/auth/router.ts b/packages/server/src/server/auth/router.ts index a8bed2210..3f820396c 100644 --- a/packages/server/src/server/auth/router.ts +++ b/packages/server/src/server/auth/router.ts @@ -1,16 +1,17 @@ +import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk-core'; import type { RequestHandler } from 'express'; import express from 'express'; -import type { ClientRegistrationHandlerOptions } from './handlers/register.js'; -import { clientRegistrationHandler } from './handlers/register.js'; -import type { TokenHandlerOptions } from './handlers/token.js'; -import { tokenHandler } from './handlers/token.js'; + import type { AuthorizationHandlerOptions } from './handlers/authorize.js'; import { authorizationHandler } from './handlers/authorize.js'; +import { metadataHandler } from './handlers/metadata.js'; +import type { ClientRegistrationHandlerOptions } from './handlers/register.js'; +import { clientRegistrationHandler } from './handlers/register.js'; import type { RevocationHandlerOptions } from './handlers/revoke.js'; import { revocationHandler } from './handlers/revoke.js'; -import { metadataHandler } from './handlers/metadata.js'; +import type { TokenHandlerOptions } from './handlers/token.js'; +import { tokenHandler } from './handlers/token.js'; import type { OAuthServerProvider } from './provider.js'; -import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk-core'; // Check for dev mode flag that allows HTTP issuer URLs (for development/testing only) const allowInsecureIssuerUrl = diff --git a/packages/server/src/server/express.ts b/packages/server/src/server/express.ts index 304dde79a..ff23cde85 100644 --- a/packages/server/src/server/express.ts +++ b/packages/server/src/server/express.ts @@ -1,5 +1,6 @@ import type { Express } from 'express'; import express from 'express'; + import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; /** diff --git a/packages/server/src/server/inMemoryEventStore.ts b/packages/server/src/server/inMemoryEventStore.ts index f4df657c9..45c1e4d89 100644 --- a/packages/server/src/server/inMemoryEventStore.ts +++ b/packages/server/src/server/inMemoryEventStore.ts @@ -1,4 +1,5 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk-core'; + import type { EventStore } from './streamableHttp.js'; /** diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 78a99f78f..f569f2c83 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1,70 +1,70 @@ -import type { ServerOptions } from './server.js'; -import { Server } from './server.js'; import type { - AnySchema, AnyObjectSchema, - ZodRawShapeCompat, - SchemaOutput, - ShapeOutput, - Implementation, - Tool, - ListToolsResult, + AnySchema, + BaseMetadata, + CallToolRequest, CallToolResult, + CompleteRequestPrompt, + CompleteRequestResourceTemplate, CompleteResult, - PromptReference, - ResourceTemplateReference, - BaseMetadata, - Resource, - ListResourcesResult, + CreateTaskResult, + GetPromptResult, + Implementation, ListPromptsResult, + ListResourcesResult, + ListToolsResult, + LoggingMessageNotification, Prompt, PromptArgument, - GetPromptResult, + PromptReference, ReadResourceResult, - ServerRequest, + RequestHandlerExtra, + Resource, + ResourceTemplateReference, + Result, + SchemaOutput, ServerNotification, + ServerRequest, + ShapeOutput, + Tool, ToolAnnotations, - LoggingMessageNotification, - CreateTaskResult, - Result, - CompleteRequestPrompt, - CompleteRequestResourceTemplate, - CallToolRequest, ToolExecution, + Transport, Variables, - RequestHandlerExtra, - Transport + ZodRawShapeCompat } from '@modelcontextprotocol/sdk-core'; import { - normalizeObjectSchema, - safeParseAsync, + assertCompleteRequestPrompt, + assertCompleteRequestResourceTemplate, + CallToolRequestSchema, + CompleteRequestSchema, + ErrorCode, + getLiteralValue, getObjectShape, - objectFromShape, getParseErrorMessage, + GetPromptRequestSchema, getSchemaDescription, isSchemaOptional, - getLiteralValue, - toJsonSchemaCompat, - McpError, - ErrorCode, + ListPromptsRequestSchema, + ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema, ListToolsRequestSchema, - CallToolRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema, - CompleteRequestSchema, - assertCompleteRequestPrompt, - assertCompleteRequestResourceTemplate, + McpError, + normalizeObjectSchema, + objectFromShape, + ReadResourceRequestSchema, + safeParseAsync, + toJsonSchemaCompat, UriTemplate, validateAndWarnToolName } from '@modelcontextprotocol/sdk-core'; -import { isCompletable, getCompleter } from './completable.js'; +import { ZodOptional } from 'zod'; -import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.js'; import type { ToolTaskHandler } from '../experimental/tasks/interfaces.js'; -import { ZodOptional } from 'zod'; +import { ExperimentalMcpServerTasks } from '../experimental/tasks/mcp-server.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. diff --git a/packages/server/src/server/middleware/hostHeaderValidation.ts b/packages/server/src/server/middleware/hostHeaderValidation.ts index bb0bfb47f..f46178db3 100644 --- a/packages/server/src/server/middleware/hostHeaderValidation.ts +++ b/packages/server/src/server/middleware/hostHeaderValidation.ts @@ -1,4 +1,4 @@ -import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; /** * Express middleware for DNS rebinding protection. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index d791b72ab..9ecf1e04d 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -1,66 +1,67 @@ import type { - JsonSchemaType, - jsonSchemaValidator, AnyObjectSchema, - SchemaOutput, - NotificationOptions, - ProtocolOptions, - RequestOptions, ClientCapabilities, CreateMessageRequest, - CreateMessageResult, - CreateMessageResultWithTools, CreateMessageRequestParamsBase, CreateMessageRequestParamsWithTools, + CreateMessageResult, + CreateMessageResultWithTools, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, Implementation, InitializeRequest, InitializeResult, + JsonSchemaType, + jsonSchemaValidator, ListRootsRequest, LoggingLevel, LoggingMessageNotification, + Notification, + NotificationOptions, + ProtocolOptions, + Request, + RequestHandlerExtra, + RequestOptions, ResourceUpdatedNotification, + Result, + SchemaOutput, ServerCapabilities, ServerNotification, ServerRequest, ServerResult, ToolResultContent, ToolUseContent, - Request, - Notification, - Result, ZodV3Internal, - ZodV4Internal, - RequestHandlerExtra + ZodV4Internal } from '@modelcontextprotocol/sdk-core'; import { - mergeCapabilities, - Protocol, + AjvJsonSchemaValidator, + assertClientRequestTaskCapability, + assertToolsCallTaskCapability, + CallToolRequestSchema, + CallToolResultSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + CreateTaskResultSchema, ElicitResultSchema, EmptyResultSchema, ErrorCode, + getObjectShape, InitializedNotificationSchema, InitializeRequestSchema, + isZ4Schema, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, McpError, - SetLevelRequestSchema, - SUPPORTED_PROTOCOL_VERSIONS, - CallToolRequestSchema, - CallToolResultSchema, - CreateTaskResultSchema, - getObjectShape, - isZ4Schema, + mergeCapabilities, + Protocol, safeParse, - AjvJsonSchemaValidator, - assertToolsCallTaskCapability, - assertClientRequestTaskCapability + SetLevelRequestSchema, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk-core'; + import { ExperimentalServerTasks } from '../experimental/tasks/server.js'; export type ServerOptions = ProtocolOptions & { diff --git a/packages/server/src/server/sse.ts b/packages/server/src/server/sse.ts index 02a06709c..8afeb7de8 100644 --- a/packages/server/src/server/sse.ts +++ b/packages/server/src/server/sse.ts @@ -1,10 +1,11 @@ import { randomUUID } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Transport, JSONRPCMessage, MessageExtraInfo, RequestInfo, AuthInfo } from '@modelcontextprotocol/sdk-core'; +import { URL } from 'node:url'; + +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestInfo, Transport } from '@modelcontextprotocol/sdk-core'; import { JSONRPCMessageSchema } from '@modelcontextprotocol/sdk-core'; -import getRawBody from 'raw-body'; import contentType from 'content-type'; -import { URL } from 'node:url'; +import getRawBody from 'raw-body'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 5bf5657be..f6fce4568 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,7 +1,8 @@ import process from 'node:process'; import type { Readable, Writable } from 'node:stream'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; + import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/sdk-core'; +import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/sdk-core'; /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' stdin and writing to stdout. diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 5135caf48..1f7d31dde 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -1,17 +1,18 @@ +import { randomUUID } from 'node:crypto'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Transport, MessageExtraInfo, RequestInfo, JSONRPCMessage, RequestId, AuthInfo } from '@modelcontextprotocol/sdk-core'; + +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, RequestInfo, Transport } from '@modelcontextprotocol/sdk-core'; import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, + isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, - SUPPORTED_PROTOCOL_VERSIONS, - DEFAULT_NEGOTIATED_PROTOCOL_VERSION, - isJSONRPCErrorResponse + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/sdk-core'; -import getRawBody from 'raw-body'; import contentType from 'content-type'; -import { randomUUID } from 'node:crypto'; +import getRawBody from 'raw-body'; const MAXIMUM_MESSAGE_SIZE = '4mb'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc5844945..574b4ee3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,9 @@ importers: eslint-plugin-n: specifier: ^17.23.1 version: 17.23.1(eslint@9.39.1)(typescript@5.9.3) + eslint-plugin-simple-import-sort: + specifier: ^12.1.1 + version: 12.1.1(eslint@9.39.1) prettier: specifier: 3.6.2 version: 3.6.2 @@ -1641,6 +1644,11 @@ packages: peerDependencies: eslint: '>=8.23.0' + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3697,6 +3705,10 @@ snapshots: transitivePeerDependencies: - typescript + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.1): + dependencies: + eslint: 9.39.1 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 From c3be9759b28d5048c916c7089de531abcd015094 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 15:35:14 +0200 Subject: [PATCH 21/22] clean up --- common/eslint-config/eslint.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 91640ea37..0ceb926d4 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -34,7 +34,6 @@ export default tseslint.config( 'import/resolver': { typescript: { // Let the TS resolver handle NodeNext-style imports like "./foo.js" - // while the actual file is "./foo.ts" extensions: ['.js', '.jsx', '.ts', '.tsx', '.d.ts'], // Use the tsconfig in each package root (when running ESLint from that package) project: 'tsconfig.json' From c3353c93796c6dd0ecfd34fb7529e3ca7a482eb3 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 12 Dec 2025 16:06:51 +0200 Subject: [PATCH 22/22] pr-pkg-new update --- .github/workflows/publish.yml | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 00ffd6efe..2a1e20c1e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,8 @@ name: Publish Any Commit + permissions: contents: read + on: pull_request: push: @@ -14,14 +16,31 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 with: node-version: 24 - cache: npm + 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 for @modelcontextprotocol/sdk-client + working-directory: packages/client + run: npx pkg-pr-new publish - - run: npm ci - - name: Build - run: npm run build - - name: Publish + - name: Publish preview for @modelcontextprotocol/sdk-server + working-directory: packages/server run: npx pkg-pr-new publish