diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ea619bd01..911c08bdf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,7 @@ on: branches: - main pull_request: + workflow_dispatch: release: types: [published] @@ -62,6 +63,16 @@ jobs: - run: npm ci - - run: npm publish --provenance --access public + - name: Determine npm tag + id: npm-tag + run: | + VERSION=$(node -p "require('./package.json').version") + if [[ "$VERSION" == *"-beta"* ]]; then + echo "tag=--tag beta" >> $GITHUB_OUTPUT + else + echo "tag=" >> $GITHUB_OUTPUT + fi + + - run: npm publish --provenance --access public ${{ steps.npm-tag.outputs.tag }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 6c4bf1a6b..a1b83bc4f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ out .DS_Store dist/ + +# IDE +.idea/ diff --git a/README.md b/README.md index 92f56786f..bcdf0d841 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,11 @@ The Model Context Protocol allows applications to provide context for LLMs in a ## Installation ```bash -npm install @modelcontextprotocol/sdk +npm install @modelcontextprotocol/sdk zod ``` +This SDK has a **required peer dependency** on `zod` for schema validation. The SDK internally imports from `zod/v4`, but maintains backwards compatibility with projects using Zod v3.25 or later. You can use either API in your code by importing from `zod/v3` or `zod/v4`: + ## Quick Start Let's create a simple MCP server that exposes a calculator tool and some data. Save the following as `server.ts`: @@ -58,7 +60,7 @@ Let's create a simple MCP server that exposes a calculator tool and some data. S import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { z } from 'zod'; +import * as z from 'zod/v4'; // Create an MCP server const server = new McpServer({ @@ -130,7 +132,7 @@ app.listen(port, () => { }); ``` -Install the deps with `npm install @modelcontextprotocol/sdk express zod@3`, and run with `npx -y tsx server.ts`. +Install the deps with `npm install @modelcontextprotocol/sdk express zod`, and run with `npx -y tsx server.ts`. You can connect to it using any MCP client that supports streamable http, such as: @@ -477,7 +479,7 @@ MCP servers can request LLM completions from connected clients that support samp import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const mcpServer = new McpServer({ name: 'tools-with-sample-server', @@ -561,7 +563,7 @@ For most use cases where session management isn't needed: import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const app = express(); app.use(express.json()); @@ -796,7 +798,7 @@ A simple server demonstrating resources, tools, and prompts: ```typescript import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const server = new McpServer({ name: 'echo-server', @@ -866,7 +868,7 @@ A more complex example showing database integration: import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import sqlite3 from 'sqlite3'; import { promisify } from 'util'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const server = new McpServer({ name: 'sqlite-explorer', @@ -961,7 +963,7 @@ If you want to offer an initial set of tools/prompts/resources, but later add ad import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const server = new McpServer({ name: 'Dynamic Example', @@ -1175,7 +1177,7 @@ await server.connect(transport); ### Eliciting User Input -MCP servers can request additional information from users through the elicitation feature. This is useful for interactive workflows where the server needs user input or confirmation: +MCP servers can request non-sensitive information from users through the form elicitation capability. This is useful for interactive workflows where the server needs user input or confirmation: ```typescript // Server-side: Restaurant booking tool that asks for alternatives @@ -1208,6 +1210,7 @@ server.registerTool( if (!available) { // Ask user if they want to try alternative dates const result = await server.server.elicitInput({ + mode: 'form', message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, requestedSchema: { type: 'object', @@ -1274,7 +1277,7 @@ server.registerTool( ); ``` -Client-side: Handle elicitation requests +On the client side, handle form elicitation requests: ```typescript // This is a placeholder - implement based on your UI framework @@ -1299,7 +1302,85 @@ client.setRequestHandler(ElicitRequestSchema, async request => { }); ``` -**Note**: Elicitation requires client support. Clients must declare the `elicitation` capability during initialization. +When calling `server.elicitInput`, prefer to explicitly set `mode: 'form'` for new code. Omitting the mode continues to work for backwards compatibility and defaults to form elicitation. + +Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: + +```typescript +const client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } +); +``` + +**Note**: Form elicitation **must** only be used to gather non-sensitive information. For sensitive information such as API keys or secrets, use URL elicitation instead. + +### Eliciting URL Actions + +MCP servers can prompt the user to perform a URL-based action through URL elicitation. This is useful for securely gathering sensitive information such as API keys or secrets, or for redirecting users to secure web-based flows. + +```typescript +// Server-side: Prompt the user to navigate to a URL +const result = await server.server.elicitInput({ + mode: 'url', + message: 'Please enter your API key', + elicitationId: '550e8400-e29b-41d4-a716-446655440000', + url: 'http://localhost:3000/api-key' +}); + +// Alternative, return an error from within a tool: +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: '550e8400-e29b-41d4-a716-446655440000' + } +]); +``` + +On the client side, handle URL elicitation requests: + +```typescript +client.setRequestHandler(ElicitRequestSchema, async request => { + if (request.params.mode !== 'url') { + throw new McpError(ErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`); + } + + // At a minimum, implement a UI that: + // - Display the full URL and server reason to prevent phishing + // - Explicitly ask the user for consent, with clear decline/cancel options + // - Open the URL in the system (not embedded) browser + // Optionally, listen for a `nofifications/elicitation/complete` message from the server +}); +``` + +Elicitation is a client capability. Clients must declare the `elicitation` capability during initialization: + +```typescript +const client = new Client( + { + name: 'example-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } +); +``` ### Writing MCP Clients diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index d15de5a17..000000000 --- a/jest.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createDefaultEsmPreset } from 'ts-jest'; - -const defaultEsmPreset = createDefaultEsmPreset(); - -/** @type {import('ts-jest').JestConfigWithTsJest} **/ -export default { - ...defaultEsmPreset, - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - '^pkce-challenge$': '/src/__mocks__/pkce-challenge.ts' - }, - transformIgnorePatterns: ['/node_modules/(?!eventsource)/'], - testPathIgnorePatterns: ['/node_modules/', '/dist/'] -}; diff --git a/package-lock.json b/package-lock.json index 8b245aa0c..b6720b978 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0", "license": "MIT", "dependencies": { "ajv": "^8.17.1", @@ -20,526 +20,47 @@ "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@eslint/js": "^9.8.0", - "@jest-mock/express": "^3.0.0", + "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", - "@types/eslint__js": "^8.42.3", "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", - "@types/jest": "^29.5.12", - "@types/node": "^22.0.2", + "@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", - "jest": "^29.7.0", "prettier": "3.6.2", "supertest": "^7.0.0", - "ts-jest": "^29.2.4", "tsx": "^4.16.5", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", + "vitest": "^4.0.8", "ws": "^8.18.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true + }, + "zod": { + "optional": false } } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.0.tgz", - "integrity": "sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.0.tgz", - "integrity": "sha512-/AIkAmInnWwgEAJGQr9vY0c66Mj6kjkE2ZPB1PurTRaRAh3U+J45sAQMjQDJdh4WbR3l0x5xkimXBKyBXXAu2w==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.26.0", - "@babel/types": "^7.26.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", - "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/generator": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/template": "^7.25.9", - "@babel/types": "^7.25.9", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -1082,12 +603,16 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", - "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "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": { @@ -1159,461 +684,24 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "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, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "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, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest-mock/express": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@jest-mock/express/-/express-3.0.0.tgz", - "integrity": "sha512-omOl6bh4EOUbp9bvcPSBZKaG8nAtBlhVSUhLx0brHrNpEDn+fMtQp58NkhdY3OoUfXjb7go/EcSYwk+H1BVLdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "^5.0.0" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "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/" + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodelib/fs.scandir": { @@ -1661,70 +749,320 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "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, - "dependencies": { - "type-detect": "4.0.8" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "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, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "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, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "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, - "dependencies": { - "@babel/types": "^7.0.0" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "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, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "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, - "dependencies": { - "@babel/types": "^7.20.7" - } + "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", @@ -1737,6 +1075,17 @@ "@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", @@ -1779,30 +1128,19 @@ "@types/node": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint__js": { - "version": "8.42.3", - "resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-8.42.3.tgz", - "integrity": "sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==", + "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, - "dependencies": { - "@types/eslint": "*" - } + "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "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", @@ -1836,55 +1174,12 @@ "@types/send": "*" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "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/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", - "dev": true, - "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1905,12 +1200,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "22.8.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.1.tgz", - "integrity": "sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==", + "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.19.8" + "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { @@ -1948,12 +1244,6 @@ "@types/send": "*" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true - }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", @@ -1987,21 +1277,6 @@ "@types/node": "*" } }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -2349,50 +1624,134 @@ "win32" ] }, - "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==", + "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": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@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" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "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, - "bin": { - "acorn": "bin/acorn" + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "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==", + "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, - "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" + "@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.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": 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, + "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", @@ -2416,30 +1775,6 @@ } } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2455,19 +1790,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2481,11 +1803,15 @@ "dev": true, "license": "MIT" }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true + "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", @@ -2494,116 +1820,6 @@ "dev": true, "license": "MIT" }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", - "dev": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2630,23 +1846,6 @@ "node": ">=18" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/body-parser/node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2684,65 +1883,6 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2789,35 +1929,16 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "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": ">=6" + "node": ">=18" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001673", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001673.tgz", - "integrity": "sha512-WTrjUCSMp3LYX0nE12ECkV0a+e6LC85E0Auz75555/qr78Oc8YWhEPNfDd6SHdtlCMSzqtuXY0uyEMNRcsKpKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2834,66 +1955,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", - "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", - "dev": true - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2961,12 +2022,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -3004,27 +2059,6 @@ "node": ">= 0.10" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", @@ -3039,9 +2073,10 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "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" }, @@ -3054,35 +2089,12 @@ } } }, - "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "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/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3111,15 +2123,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -3131,15 +2134,6 @@ "wrappy": "1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3160,45 +2154,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.47", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.47.tgz", - "integrity": "sha512-zS5Yer0MOYw4rtK2iq43cJagHZ8sXN0jDHDKzB+86gSBSAI4v07S97mcq+Gs2vclAxSh1j7vOAHxSVgduiiuVQ==", - "dev": true - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3208,15 +2163,6 @@ "node": ">= 0.8" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3235,6 +2181,13 @@ "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", @@ -3304,15 +2257,6 @@ "@esbuild/win32-x64": "0.25.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -3384,1716 +2328,809 @@ }, "peerDependencies": { "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "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-scope": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", - "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", - "dev": true, - "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.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", - "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", - "dev": true, - "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.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", - "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", - "dev": true, - "dependencies": { - "acorn": "^8.12.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "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, - "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/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/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", - "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.0.1", - "content-disposition": "^1.0.0", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "^1.2.1", - "debug": "4.3.6", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "^2.0.0", - "fresh": "2.0.0", - "http-errors": "2.0.0", - "merge-descriptors": "^2.0.0", - "methods": "~1.1.2", - "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.13.0", - "range-parser": "~1.2.1", - "router": "^2.0.0", - "safe-buffer": "5.2.1", - "send": "^1.1.0", - "serve-static": "^2.1.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "^2.0.0", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "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/express/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" - }, - "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-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "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/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "dependencies": { - "bser": "2.1.1" - } - }, - "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/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.0.0.tgz", - "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "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/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "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" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "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, - "engines": { - "node": ">=6.9.0" + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/eslint-scope": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "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" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/eslint-visitor-keys": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, "engines": { - "node": ">=8.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "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==", + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", + "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", + "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "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, - "engines": { - "node": ">=10" + "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": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "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==", + "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.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", "dev": true, "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "acorn": "^8.12.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://opencollective.com/eslint" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "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": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "estraverse": "^5.1.0" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=0.10" } }, - "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==", + "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, "dependencies": { - "is-glob": "^4.0.3" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=10.13.0" + "node": ">=4.0" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "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": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4.0" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "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", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "dependencies": { + "@types/estree": "^1.0.0" } }, - "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 - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "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==", + "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": ">=8" + "node": ">=0.10.0" } }, - "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==", + "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.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "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, + "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": { - "has-symbols": "^1.0.3" + "eventsource-parser": "^3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18.0.0" } }, - "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" - }, + "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": ">= 0.4" + "node": ">=18.0.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "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/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "node_modules/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", + "license": "MIT", "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", "depd": "2.0.0", - "inherits": "2.0.4", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", + "http-errors": "2.0.0", + "merge-descriptors": "^2.0.0", + "methods": "~1.1.2", + "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.13.0", + "range-parser": "~1.2.1", + "router": "^2.0.0", + "safe-buffer": "5.2.1", + "send": "^1.1.0", + "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", - "toidentifier": "1.0.1" + "type-is": "^2.0.0", + "utils-merge": "1.0.1", + "vary": "~1.1.2" }, "engines": { - "node": ">= 0.8" + "node": ">= 18" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, + "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": ">=10.17.0" + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/iconv-lite": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", - "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "node_modules/express/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "ms": "2.1.2" }, "engines": { - "node": ">=0.10.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "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/express/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "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/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" }, - "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": ">=8.6.0" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "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==", + "is-glob": "^4.0.1" + }, "engines": { - "node": ">= 0.10" + "node": ">= 6" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "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/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "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/is-extglob": { + "node_modules/fast-safe-stringify": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "dev": true, - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } + "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/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, - "engines": { - "node": ">=6" + "dependencies": { + "reusify": "^1.0.4" } }, - "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==", + "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": { - "is-extglob": "^2.1.1" + "flat-cache": "^4.0.0" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" + "node": ">=16.0.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/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isexe": { + "node_modules/finalhandler": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, "engines": { - "node": ">=8" + "node": ">= 0.8" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" + "ms": "2.0.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.8" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-source-maps": { + "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": ">=10" + "node": ">=16" } }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } + "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/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "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": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" + "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": ">=10" + "node": ">= 6" } }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "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, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">= 0.6" } }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "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": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" + "mime-db": "1.52.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.6" } }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "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": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">=14.0.0" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" } }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">= 0.6" } }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.8" } }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "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, - "dependencies": { - "detect-newline": "^3.0.0" - }, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.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/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, + "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": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, + "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": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" + "node": ">= 0.4" } }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "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": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "resolve-pkg-maps": "^1.0.0" }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "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": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "is-glob": "^4.0.3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=10.13.0" } }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "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, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "engines": { + "node": ">=18" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "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, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=8" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, + "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": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "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": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "function-bind": "^1.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.4" } }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 0.8" } }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "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, - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": ">= 4" } }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.8.19" } }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "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": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.10" } }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "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, - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "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": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.10.0" } }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "node": ">=0.12.0" } }, - "node_modules/js-tokens": { + "node_modules/is-promise": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "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/js-yaml": { "version": "4.1.0", @@ -5107,30 +3144,12 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "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-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "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", @@ -5143,18 +3162,6 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5164,24 +3171,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5195,12 +3184,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5216,67 +3199,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true - }, "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/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "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": { - "tmpl": "1.0.5" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -5309,12 +3245,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5379,15 +3309,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5405,6 +3326,25 @@ "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", @@ -5420,39 +3360,6 @@ "node": ">= 0.6" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5494,21 +3401,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5556,43 +3448,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "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, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "callsites": "^3.0.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/parseurl": { @@ -5613,15 +3478,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5630,12 +3486,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", @@ -5645,6 +3495,13 @@ "node": ">=16" } }, + "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", @@ -5663,15 +3520,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -5681,68 +3529,33 @@ "node": ">=16.20.0" } }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "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": { - "p-limit": "^2.2.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=8" + "node": "^10 || ^12 || >=14" } }, "node_modules/prelude-ls": { @@ -5770,45 +3583,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5831,22 +3605,6 @@ "node": ">=6" } }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ] - }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -5916,21 +3674,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5940,44 +3683,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5996,15 +3701,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -6015,6 +3711,48 @@ "node": ">=0.10.0" } }, + "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.1.0", "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", @@ -6077,15 +3815,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/send": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", @@ -6250,72 +3979,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "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, - "engines": { - "node": ">=8" - } + "license": "ISC" }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "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/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "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, - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/statuses": { "version": "2.0.1", @@ -6325,62 +4011,12 @@ "node": ">= 0.8" } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "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, - "engines": { - "node": ">=6" - } + "license": "MIT" }, "node_modules/strip-json-comments": { "version": "3.1.1", @@ -6441,44 +4077,84 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "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", "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "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, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6511,66 +4187,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", - "dev": true, - "dependencies": { - "bs-logger": "^0.2.6", - "ejs": "^3.1.10", - "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.6.3", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tsx": { "version": "4.19.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", @@ -6603,27 +4219,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", @@ -6675,10 +4270,11 @@ } }, "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "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", @@ -6688,36 +4284,6 @@ "node": ">= 0.8" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6736,35 +4302,223 @@ "node": ">= 0.4.0" } }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "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": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" + "@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": ">=10.12.0" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "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==", + "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", "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "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", "dependencies": { - "makeerror": "1.0.12" + "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": { @@ -6781,6 +4535,23 @@ "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", @@ -6790,41 +4561,11 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "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/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -6846,48 +4587,6 @@ } } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6901,21 +4600,21 @@ } }, "node_modules/zod": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", - "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.24.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", - "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "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.24.1" + "zod": "^3.25 || ^4" } } } diff --git a/package.json b/package.json index d8eebaeb9..9aa77ff2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.22.0", + "version": "1.23.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -71,7 +71,8 @@ "lint": "eslint src/ && prettier --check .", "lint:fix": "eslint src/ --fix && prettier --write .", "check": "npm run typecheck && npm run lint", - "test": "jest", + "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" @@ -88,41 +89,41 @@ "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1" + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { "@cfworker/json-schema": { "optional": true + }, + "zod": { + "optional": false } }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@eslint/js": "^9.8.0", - "@jest-mock/express": "^3.0.0", + "@eslint/js": "^9.39.1", "@types/content-type": "^1.1.8", "@types/cors": "^2.8.17", "@types/cross-spawn": "^6.0.6", - "@types/eslint__js": "^8.42.3", "@types/eventsource": "^1.1.15", "@types/express": "^5.0.0", - "@types/jest": "^29.5.12", - "@types/node": "^22.0.2", + "@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", - "jest": "^29.7.0", "prettier": "3.6.2", "supertest": "^7.0.0", - "ts-jest": "^29.2.4", "tsx": "^4.16.5", "typescript": "^5.5.4", "typescript-eslint": "^8.0.0", + "vitest": "^4.0.8", "ws": "^8.18.0" }, "resolutions": { diff --git a/spec.types.ts b/spec.types.ts deleted file mode 100644 index 9c8a0baf9..000000000 --- a/spec.types.ts +++ /dev/null @@ -1,1578 +0,0 @@ -/* JSON-RPC types */ - -/** - * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. - * - * @internal - */ -export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError; - -/** @internal */ -export const LATEST_PROTOCOL_VERSION = 'DRAFT-2025-v3'; -/** @internal */ -export const JSONRPC_VERSION = '2.0'; - -/** - * A progress token, used to associate progress notifications with the original request. - */ -export type ProgressToken = string | number; - -/** - * An opaque token used to represent a cursor for pagination. - */ -export type Cursor = string; - -/** @internal */ -export interface Request { - method: string; - params?: { - /** - * 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; - }; - [key: string]: unknown; - }; -} - -/** @internal */ -export interface Notification { - method: string; - params?: { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; - [key: string]: unknown; - }; -} - -export interface Result { - /** - * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. - */ - _meta?: { [key: string]: unknown }; - [key: string]: unknown; -} - -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. - */ -export type RequestId = string | number; - -/** - * A request that expects a response. - */ -export interface JSONRPCRequest extends Request { - jsonrpc: typeof JSONRPC_VERSION; - id: RequestId; -} - -/** - * A notification which does not expect a response. - */ -export interface JSONRPCNotification extends Notification { - jsonrpc: typeof JSONRPC_VERSION; -} - -/** - * A successful (non-error) response to a request. - */ -export interface JSONRPCResponse { - jsonrpc: typeof JSONRPC_VERSION; - id: RequestId; - result: Result; -} - -// Standard JSON-RPC error codes -/** @internal */ -export const PARSE_ERROR = -32700; -/** @internal */ -export const INVALID_REQUEST = -32600; -/** @internal */ -export const METHOD_NOT_FOUND = -32601; -/** @internal */ -export const INVALID_PARAMS = -32602; -/** @internal */ -export const INTERNAL_ERROR = -32603; - -/** - * A response to a request that indicates an error occurred. - */ -export interface JSONRPCError { - jsonrpc: typeof JSONRPC_VERSION; - id: RequestId; - error: Error; -} - -/* Empty result */ -/** - * A response that indicates success but carries no data. - */ -export type EmptyResult = Result; - -/* 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. - * - * @category notifications/cancelled - */ -export interface CancelledNotification extends JSONRPCNotification { - method: 'notifications/cancelled'; - params: { - /** - * The ID of the request to cancel. - * - * This MUST correspond to the ID of a request previously issued in the same direction. - */ - requestId: RequestId; - - /** - * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. - */ - reason?: string; - }; -} - -/* Initialization */ -/** - * 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: { - /** - * 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; - }; -} - -/** - * 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'; -} - -/** - * 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 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?: object; - /** - * Present if the client supports elicitation from the server. - */ - elicitation?: 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. - */ -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; - }; -} - -/** - * An optionally-sized icon that can be displayed in a user interface. - */ -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 - */ -export interface Implementation extends BaseMetadata, Icons { - version: 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'; -} - -/* Progress notifications */ -/** - * 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: { - /** - * 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; - }; -} - -/* Pagination */ -/** @internal */ -export interface PaginatedRequest extends JSONRPCRequest { - params?: { - /** - * An opaque token representing the current pagination position. - * If provided, the server should return results starting after this cursor. - */ - cursor?: Cursor; - }; -} - -/** @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[]; -} - -/** - * 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: { - /** - * 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: string; - }; -} - -/** - * 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'; -} - -/** - * 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: { - /** - * The URI of the resource to subscribe to. The URI can use any protocol; it is up to the server how to interpret it. - * - * @format uri - */ - uri: string; - }; -} - -/** - * 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: { - /** - * The URI of the resource to unsubscribe from. - * - * @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: { - /** - * 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 known resource that the server is capable of reading. - */ -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. - */ -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. - */ -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 }; -} - -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; -} - -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[]; -} - -/** - * Used by the client to get a prompt provided by the server. - * - * @category prompts/get - */ -export interface GetPromptRequest extends JSONRPCRequest { - method: 'prompts/get'; - params: { - /** - * The name of the prompt or prompt template. - */ - name: string; - /** - * Arguments to use for templating the prompt. - */ - arguments?: { [key: string]: string }; - }; -} - -/** - * 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. - */ -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. - */ -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. - */ -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. - */ -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. - */ -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. - */ -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'; -} - -/* 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; -} - -/** - * Used by the client to invoke a tool provided by the server. - * - * @category tools/call - */ -export interface CallToolRequest extends JSONRPCRequest { - method: 'tools/call'; - params: { - name: string; - arguments?: { [key: string]: unknown }; - }; -} - -/** - * 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'; -} - -/** - * 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 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; -} - -/** - * Definition for a tool the client can call. - */ -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: { - type: 'object'; - properties?: { [key: string]: object }; - required?: string[]; - }; - - /** - * An optional JSON Schema object defining the structure of the tool's output returned in - * the structuredContent field of a CallToolResult. - */ - outputSchema?: { - 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 }; -} - -/* Logging */ -/** - * 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: { - /** - * 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; - }; -} - -/** - * 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: { - /** - * 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; - }; -} - -/** - * 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 - */ -export type LoggingLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency'; - -/* Sampling */ -/** - * 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: { - 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. - */ - 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; - }; -} - -/** - * The client's response to a sampling/create_message 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. - */ - stopReason?: 'endTurn' | 'stopSequence' | 'maxTokens' | string; -} - -/** - * Describes a message issued to or received from an LLM API. - */ -export interface SamplingMessage { - role: Role; - content: TextContent | ImageContent | AudioContent; -} - -/** - * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed - */ -export interface Annotations { - /** - * Describes who the intended customer of this object or data is. - * - * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["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; -} - -export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; - -/** - * Text provided to or from an LLM. - */ -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. - */ -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. - */ -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 }; -} - -/** - * 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. - */ -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. - */ -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 */ -/** - * 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: { - 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 }; - }; - }; -} - -/** - * 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. - */ -export interface ResourceTemplateReference { - type: 'ref/resource'; - /** - * The URI or URI template of the resource. - * - * @format uri-template - */ - uri: string; -} - -/** - * Identifies a prompt. - */ -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'; -} - -/** - * 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. - */ -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'; -} - -/** - * 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: { - /** - * The message to present to the user. - */ - message: string; - /** - * A restricted subset of JSON Schema. - * Only top-level properties are allowed, without nesting. - */ - requestedSchema: { - type: 'object'; - properties: { - [key: string]: PrimitiveSchemaDefinition; - }; - required?: string[]; - }; - }; -} - -/** - * Restricted schema definitions that only allow primitive types - * without nested objects or arrays. - */ -export type PrimitiveSchemaDefinition = StringSchema | NumberSchema | BooleanSchema | EnumSchema; - -export interface StringSchema { - type: 'string'; - title?: string; - description?: string; - minLength?: number; - maxLength?: number; - format?: 'email' | 'uri' | 'date' | 'date-time'; - default?: string; -} - -export interface NumberSchema { - type: 'number' | 'integer'; - title?: string; - description?: string; - minimum?: number; - maximum?: number; - default?: number; -} - -export interface BooleanSchema { - type: 'boolean'; - title?: string; - description?: string; - default?: boolean; -} - -export interface EnumSchema { - type: 'string'; - title?: string; - description?: string; - enum: string[]; - enumNames?: string[]; // Display names for enum values - default?: string; -} - -/** - * 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". - * Contains values matching the requested schema. - */ - content?: { [key: string]: string | number | boolean }; -} - -/* Client messages */ -/** @internal */ -export type ClientRequest = - | PingRequest - | InitializeRequest - | CompleteRequest - | SetLevelRequest - | GetPromptRequest - | ListPromptsRequest - | ListResourcesRequest - | ListResourceTemplatesRequest - | ReadResourceRequest - | SubscribeRequest - | UnsubscribeRequest - | CallToolRequest - | ListToolsRequest; - -/** @internal */ -export type ClientNotification = CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification; - -/** @internal */ -export type ClientResult = EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult; - -/* Server messages */ -/** @internal */ -export type ServerRequest = PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest; - -/** @internal */ -export type ServerNotification = - | CancelledNotification - | ProgressNotification - | LoggingMessageNotification - | ResourceUpdatedNotification - | ResourceListChangedNotification - | ToolListChangedNotification - | PromptListChangedNotification; - -/** @internal */ -export type ServerResult = - | EmptyResult - | InitializeResult - | CompleteResult - | GetPromptResult - | ListPromptsResult - | ListResourceTemplatesResult - | ListResourcesResult - | ReadResourceResult - | CallToolResult - | ListToolsResult; diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8124fe768..0e3a544a2 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -11,13 +11,23 @@ import { extractWWWAuthenticateParams, auth, type OAuthClientProvider, - selectClientAuthMethod + selectClientAuthMethod, + isHttpsUrl } from './auth.js'; -import { ServerError } from '../server/auth/errors.js'; +import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js'; import { AuthorizationServerMetadata } from '../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 = jest.fn(); +const mockFetch = vi.fn(); global.fetch = mockFetch; describe('OAuth Authorization', () => { @@ -30,7 +40,7 @@ describe('OAuth Authorization', () => { const resourceUrl = 'https://resource.example.com/.well-known/oauth-protected-resource'; const mockResponse = { headers: { - get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null)) + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}"` : null)) } } as unknown as Response; @@ -41,7 +51,7 @@ describe('OAuth Authorization', () => { const scope = 'read'; const mockResponse = { headers: { - get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null)) + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp", scope="${scope}"` : null)) } } as unknown as Response; @@ -53,7 +63,7 @@ describe('OAuth Authorization', () => { const scope = 'read'; const mockResponse = { headers: { - get: jest.fn(name => + get: vi.fn(name => name === 'WWW-Authenticate' ? `Basic realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null ) } @@ -65,7 +75,7 @@ describe('OAuth Authorization', () => { it('returns empty object if resource_metadata and scope not present', async () => { const mockResponse = { headers: { - get: jest.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null)) + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer realm="mcp"` : null)) } } as unknown as Response; @@ -77,7 +87,7 @@ describe('OAuth Authorization', () => { const scope = 'read'; const mockResponse = { headers: { - get: jest.fn(name => + get: vi.fn(name => name === 'WWW-Authenticate' ? `Bearer realm="mcp", resource_metadata="${resourceUrl}", scope="${scope}"` : null ) } @@ -85,6 +95,16 @@ describe('OAuth Authorization', () => { 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', () => { @@ -393,7 +413,7 @@ describe('OAuth Authorization', () => { authorization_servers: ['https://auth.example.com/'] }; - const customFetch = jest.fn().mockResolvedValue({ + const customFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => validMetadata @@ -689,7 +709,7 @@ describe('OAuth Authorization', () => { code_challenge_methods_supported: ['S256'] }; - const customFetch = jest.fn().mockResolvedValue({ + const customFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => validMetadata @@ -851,7 +871,7 @@ describe('OAuth Authorization', () => { }); it('supports custom fetch function', async () => { - const customFetch = jest.fn().mockResolvedValue({ + const customFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => validOAuthMetadata @@ -1115,14 +1135,16 @@ describe('OAuth Authorization', () => { href: 'https://auth.example.com/token' }), expect.objectContaining({ - method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' - }) + method: 'POST' }) ); - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + 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'); @@ -1217,7 +1239,7 @@ describe('OAuth Authorization', () => { }); it('supports overriding the fetch function used for requests', async () => { - const customFetch = jest.fn().mockResolvedValue({ + const customFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => validTokens @@ -1506,16 +1528,16 @@ describe('OAuth Authorization', () => { client_name: 'Test Client' }; }, - clientInformation: jest.fn(), - tokens: jest.fn(), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn() + clientInformation: vi.fn(), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn() }; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => { @@ -1567,9 +1589,9 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - mockProvider.saveClientInformation = jest.fn(); + (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, { @@ -1639,9 +1661,9 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - mockProvider.saveClientInformation = jest.fn(); + (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, { @@ -1657,7 +1679,7 @@ describe('OAuth Authorization', () => { 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'); + expect(authServerCall![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); }); it('passes resource parameter through authorization flow', async () => { @@ -1690,13 +1712,13 @@ describe('OAuth Authorization', () => { }); // Mock provider methods for authorization flow - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + (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, { @@ -1712,7 +1734,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as jest.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'); }); @@ -1760,12 +1782,12 @@ describe('OAuth Authorization', () => { }); // Mock provider methods for token exchange - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); - (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); // Call auth with authorization code const result = await auth(mockProvider, { @@ -1826,15 +1848,15 @@ describe('OAuth Authorization', () => { }); // Mock provider methods for token refresh - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue({ + (mockProvider.tokens as Mock).mockResolvedValue({ access_token: 'old-access', refresh_token: 'refresh123' }); - (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { @@ -1854,7 +1876,7 @@ describe('OAuth Authorization', () => { }); it('skips default PRM resource validation when custom validateResourceURL is provided', async () => { - const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); + const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { ...mockProvider, validateResourceURL: mockValidateResourceURL @@ -1892,13 +1914,13 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({ + (providerWithCustomValidation.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); - (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + (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, { @@ -1947,13 +1969,13 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + (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, { @@ -1969,7 +1991,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as jest.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/'); @@ -2005,13 +2027,13 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + (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, { @@ -2027,7 +2049,7 @@ describe('OAuth Authorization', () => { }) ); - const redirectCall = (mockProvider.redirectToAuthorization as jest.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); @@ -2072,12 +2094,12 @@ describe('OAuth Authorization', () => { }); // Mock provider methods for token exchange - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.codeVerifier as jest.Mock).mockResolvedValue('test-verifier'); - (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.codeVerifier as Mock).mockResolvedValue('test-verifier'); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); // Call auth with authorization code const result = await auth(mockProvider, { @@ -2135,15 +2157,15 @@ describe('OAuth Authorization', () => { }); // Mock provider methods for token refresh - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue({ + (mockProvider.tokens as Mock).mockResolvedValue({ access_token: 'old-access', refresh_token: 'refresh123' }); - (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { @@ -2163,6 +2185,135 @@ describe('OAuth Authorization', () => { 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 => { @@ -2196,13 +2347,13 @@ describe('OAuth Authorization', () => { }); // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + (mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client', client_secret: 'test-secret' }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + (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, { @@ -2222,7 +2373,7 @@ describe('OAuth Authorization', () => { }); it('supports overriding the fetch function used for requests', async () => { - const customFetch = jest.fn(); + const customFetch = vi.fn(); // Mock PRM discovery customFetch.mockResolvedValueOnce({ @@ -2258,15 +2409,15 @@ describe('OAuth Authorization', () => { redirect_uris: ['http://localhost:3000/callback'] }; }, - clientInformation: jest.fn().mockResolvedValue({ + clientInformation: vi.fn().mockResolvedValue({ client_id: 'client123', client_secret: 'secret123' }), - tokens: jest.fn().mockResolvedValue(undefined), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn().mockResolvedValue('verifier123') + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('verifier123') }; const result = await auth(mockProvider, { @@ -2547,4 +2698,415 @@ describe('OAuth Authorization', () => { 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('../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('../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('../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://github.com/service/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: 'https://github.com/service/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://github.com/service/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: 'https://github.com/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/src/client/auth.ts b/src/client/auth.ts index fba0e7bf7..536ff6859 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -21,6 +21,7 @@ import { import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; import { InvalidClientError, + InvalidClientMetadataError, InvalidGrantError, OAUTH_ERRORS, OAuthError, @@ -42,6 +43,11 @@ export interface OAuthClientProvider { */ get redirectUrl(): string | URL; + /** + * External URL the server should use to fetch client metadata document + */ + clientMetadataUrl?: string; + /** * Metadata about this OAuth client. */ @@ -379,18 +385,38 @@ async function authInternal( throw new Error('Existing OAuth client information is required when exchanging an authorization code'); } - if (!provider.saveClientInformation) { - throw new Error('OAuth client information must be saveable for dynamic registration'); + 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 fullInformation = await registerClient(authorizationServerUrl, { - metadata, - clientMetadata: provider.clientMetadata, - fetchFn - }); + const shouldUseUrlBasedClientId = supportsUrlBasedClientId && clientMetadataUrl; - await provider.saveClientInformation(fullInformation); - clientInformation = fullInformation; + 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; + } } // Exchange authorization code for tokens @@ -447,7 +473,7 @@ async function authInternal( clientInformation, state, redirectUrl: provider.redirectUrl, - scope: scope || provider.clientMetadata.scope, + scope: scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope, resource }); @@ -456,6 +482,20 @@ async function authInternal( 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, @@ -482,9 +522,9 @@ export async function selectResourceURL( } /** - * Extract resource_metadata and scope from WWW-Authenticate header. + * Extract resource_metadata, scope, and error from WWW-Authenticate header. */ -export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } { +export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { const authenticateHeader = res.headers.get('WWW-Authenticate'); if (!authenticateHeader) { return {}; @@ -495,29 +535,51 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU return {}; } - const resourceMetadataRegex = /resource_metadata="([^"]*)"/; - const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader); - - const scopeRegex = /scope="([^"]*)"/; - const scopeMatch = scopeRegex.exec(authenticateHeader); + const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined; let resourceMetadataUrl: URL | undefined; if (resourceMetadataMatch) { try { - resourceMetadataUrl = new URL(resourceMetadataMatch[1]); + resourceMetadataUrl = new URL(resourceMetadataMatch); } catch { // Ignore invalid URL } } - const scope = scopeMatch?.[1] || undefined; + const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; + const error = extractFieldFromWwwAuth(res, 'error') || undefined; return { resourceMetadataUrl, - scope + 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. diff --git a/src/client/cross-spawn.test.ts b/src/client/cross-spawn.test.ts index ca2a5005c..6ef74fe0d 100644 --- a/src/client/cross-spawn.test.ts +++ b/src/client/cross-spawn.test.ts @@ -2,33 +2,34 @@ import { StdioClientTransport, getDefaultEnvironment } from './stdio.js'; import spawn from 'cross-spawn'; import { JSONRPCMessage } from '../types.js'; import { ChildProcess } from 'node:child_process'; +import { Mock, MockedFunction } from 'vitest'; // mock cross-spawn -jest.mock('cross-spawn'); -const mockSpawn = spawn as jest.MockedFunction; +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: jest.Mock; - stdin?: { on: jest.Mock; write: jest.Mock }; - stdout?: { on: jest.Mock }; + on: Mock; + stdin?: { on: Mock; write: Mock }; + stdout?: { on: Mock }; stderr?: null; } = { - on: jest.fn((event: string, callback: () => void) => { + on: vi.fn((event: string, callback: () => void) => { if (event === 'spawn') { callback(); } return mockProcess; }), stdin: { - on: jest.fn(), - write: jest.fn().mockReturnValue(true) + on: vi.fn(), + write: vi.fn().mockReturnValue(true) }, stdout: { - on: jest.fn() + on: vi.fn() }, stderr: null }; @@ -37,7 +38,7 @@ describe('StdioClientTransport using cross-spawn', () => { }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); test('should call cross-spawn correctly', async () => { @@ -105,30 +106,30 @@ describe('StdioClientTransport using cross-spawn', () => { // get the mock process object const mockProcess: { - on: jest.Mock; + on: Mock; stdin: { - on: jest.Mock; - write: jest.Mock; - once: jest.Mock; + on: Mock; + write: Mock; + once: Mock; }; stdout: { - on: jest.Mock; + on: Mock; }; stderr: null; } = { - on: jest.fn((event: string, callback: () => void) => { + on: vi.fn((event: string, callback: () => void) => { if (event === 'spawn') { callback(); } return mockProcess; }), stdin: { - on: jest.fn(), - write: jest.fn().mockReturnValue(true), - once: jest.fn() + on: vi.fn(), + write: vi.fn().mockReturnValue(true), + once: vi.fn() }, stdout: { - on: jest.fn() + on: vi.fn() }, stderr: null }; diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 912abaac3..4c26c796c 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import { Client } from './index.js'; -import { z } from 'zod'; +import { Client, getSupportedElicitationModes } from './index.js'; import { RequestSchema, NotificationSchema, @@ -15,21 +14,181 @@ import { CallToolRequestSchema, CreateMessageRequestSchema, ElicitRequestSchema, + ElicitResultSchema, ListRootsRequestSchema, ErrorCode } from '../types.js'; import { Transport } from '../shared/transport.js'; import { Server } from '../server/index.js'; import { InMemoryTransport } from '../inMemory.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: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.method === 'initialize') { clientTransport.onmessage?.({ jsonrpc: '2.0', @@ -86,9 +245,9 @@ test('should initialize with matching protocol version', async () => { test('should initialize with supported older protocol version', async () => { const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; const clientTransport: Transport = { - start: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.method === 'initialize') { clientTransport.onmessage?.({ jsonrpc: '2.0', @@ -136,9 +295,9 @@ test('should initialize with supported older protocol version', async () => { */ test('should reject unsupported protocol version', async () => { const clientTransport: Transport = { - start: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.method === 'initialize') { clientTransport.onmessage?.({ jsonrpc: '2.0', @@ -596,78 +755,372 @@ test('should allow setRequestHandler for declared elicitation capability', () => }).toThrow('Client does not support sampling capability'); }); -/*** - * 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: z.literal('weather/get'), - params: z.object({ - city: z.string() - }) +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 GetForecastRequestSchema = RequestSchema.extend({ - method: z.literal('weather/forecast'), - params: z.object({ - city: z.string(), - days: z.number() - }) + 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'] + } }); - const WeatherForecastNotificationSchema = NotificationSchema.extend({ - method: z.literal('weather/alert'), - params: z.object({ - severity: z.enum(['warning', 'watch']), - message: z.string() - }) + 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 WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); - const WeatherNotificationSchema = WeatherForecastNotificationSchema; - const WeatherResultSchema = ResultSchema.extend({ - temperature: z.number(), - conditions: z.string() + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + let resolveResponse: ((message: unknown) => void) | undefined; + const responsePromise = new Promise(resolve => { + resolveResponse = resolve; }); - type WeatherRequest = z.infer; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; + 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); - // Create a typed Client for weather data - const weatherClient = new Client( + // 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: 'WeatherClient', - version: '1.0.0' + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' }, { capabilities: { - sampling: {} + elicitation: { + url: {} + } } } ); - // Typecheck that only valid weather requests/notifications/results are allowed - false && - weatherClient.request( + 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: 'weather/get', + method: 'elicitation/create', params: { - city: 'Seattle' + message: 'Please provide data', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } } }, - WeatherResultSchema - ); + ElicitResultSchema + ) + ).rejects.toThrow('Client does not support form-mode elicitation requests'); + + expect(handler).not.toHaveBeenCalled(); - false && - weatherClient.notification({ - method: 'weather/alert', - params: { - severity: 'warning', - message: 'Storm approaching' + 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(); }); /*** @@ -1236,3 +1689,41 @@ describe('outputSchema validation', () => { ); }); }); + +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/src/client/index.ts b/src/client/index.ts index 5770f9d7f..823aa790e 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -42,7 +42,15 @@ import { } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; -import { ZodLiteral, ZodObject, z } from 'zod'; +import { + AnyObjectSchema, + SchemaOutput, + getObjectShape, + isZ4Schema, + safeParse, + type ZodV3Internal, + type ZodV4Internal +} from '../server/zod-compat.js'; import type { RequestHandlerExtra } from '../shared/protocol.js'; /** @@ -85,6 +93,34 @@ function applyElicitationDefaults(schema: JsonSchemaType | undefined, data: unkn } } +/** + * 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. @@ -188,47 +224,80 @@ export class Client< /** * Override request handler registration to enforce client-side validation for elicitation. */ - public override setRequestHandler< - T extends ZodObject<{ - method: ZodLiteral; - }> - >( + public override setRequestHandler( requestSchema: T, handler: ( - request: z.infer, + request: SchemaOutput, extra: RequestHandlerExtra ) => ClientResult | ResultT | Promise ): void { - const method = requestSchema.shape.method.value; + 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: z.infer, + request: SchemaOutput, extra: RequestHandlerExtra ): Promise => { - const validatedRequest = ElicitRequestSchema.safeParse(request); + const validatedRequest = safeParse(ElicitRequestSchema, request); if (!validatedRequest.success) { - throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`); + // 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; + const mode = params.mode ?? 'form'; + const { supportsFormMode, supportsUrlMode } = getSupportedElicitationModes(this._capabilities.elicitation); + + if (mode === 'form' && !supportsFormMode) { + throw new McpError(ErrorCode.InvalidParams, 'Client does not support form-mode elicitation requests'); + } + + if (mode === 'url' && !supportsUrlMode) { + throw new McpError(ErrorCode.InvalidParams, 'Client does not support URL-mode elicitation requests'); } const result = await Promise.resolve(handler(request, extra)); - const validationResult = ElicitResultSchema.safeParse(result); + const validationResult = safeParse(ElicitResultSchema, result); if (!validationResult.success) { - throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation result: ${validationResult.error.message}`); + // 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; - - if ( - this._capabilities.elicitation?.applyDefaults && - validatedResult.action === 'accept' && - validatedResult.content && - validatedRequest.data.params.requestedSchema - ) { - try { - applyElicitationDefaults(validatedRequest.data.params.requestedSchema, validatedResult.content); - } catch { - // gracefully ignore errors in default application + const requestedSchema = mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined; + + if (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 + } } } diff --git a/src/client/middleware.test.ts b/src/client/middleware.test.ts index ecf799844..4f14ccd22 100644 --- a/src/client/middleware.test.ts +++ b/src/client/middleware.test.ts @@ -1,27 +1,28 @@ import { withOAuth, withLogging, applyMiddlewares, createMiddleware } from './middleware.js'; import { OAuthClientProvider } from './auth.js'; import { FetchLike } from '../shared/transport.js'; +import { MockInstance, Mocked, MockedFunction } from 'vitest'; -jest.mock('../client/auth.js', () => { - const actual = jest.requireActual('../client/auth.js'); +vi.mock('../client/auth.js', async () => { + const actual = await vi.importActual('../client/auth.js'); return { ...actual, - auth: jest.fn(), - extractWWWAuthenticateParams: jest.fn() + auth: vi.fn(), + extractWWWAuthenticateParams: vi.fn() }; }); import { auth, extractWWWAuthenticateParams } from './auth.js'; -const mockAuth = auth as jest.MockedFunction; -const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as jest.MockedFunction; +const mockAuth = auth as MockedFunction; +const mockExtractWWWAuthenticateParams = extractWWWAuthenticateParams as MockedFunction; describe('withOAuth', () => { - let mockProvider: jest.Mocked; - let mockFetch: jest.MockedFunction; + let mockProvider: Mocked; + let mockFetch: MockedFunction; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); mockProvider = { get redirectUrl() { @@ -30,16 +31,16 @@ describe('withOAuth', () => { get clientMetadata() { return { redirect_uris: ['http://localhost/callback'] }; }, - tokens: jest.fn(), - saveTokens: jest.fn(), - clientInformation: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - invalidateCredentials: jest.fn() + tokens: vi.fn(), + saveTokens: vi.fn(), + clientInformation: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() }; - mockFetch = jest.fn(); + mockFetch = vi.fn(); }); it('should add Authorization header when tokens are available (with explicit baseUrl)', async () => { @@ -371,8 +372,8 @@ describe('withOAuth', () => { }); describe('withLogging', () => { - let mockFetch: jest.MockedFunction; - let mockLogger: jest.MockedFunction< + let mockFetch: MockedFunction; + let mockLogger: MockedFunction< (input: { method: string; url: string | URL; @@ -384,17 +385,17 @@ describe('withLogging', () => { error?: Error; }) => void >; - let consoleErrorSpy: jest.SpyInstance; - let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: MockInstance; + let consoleLogSpy: MockInstance; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - mockFetch = jest.fn(); - mockLogger = jest.fn(); + mockFetch = vi.fn(); + mockLogger = vi.fn(); }); afterEach(() => { @@ -614,11 +615,11 @@ describe('withLogging', () => { }); describe('applyMiddleware', () => { - let mockFetch: jest.MockedFunction; + let mockFetch: MockedFunction; beforeEach(() => { - jest.clearAllMocks(); - mockFetch = jest.fn(); + vi.clearAllMocks(); + mockFetch = vi.fn(); }); it('should compose no middleware correctly', () => { @@ -703,7 +704,7 @@ describe('applyMiddleware', () => { }; // Use custom logger to avoid console output - const mockLogger = jest.fn(); + const mockLogger = vi.fn(); const composedFetch = applyMiddlewares(oauthMiddleware, withLogging({ logger: mockLogger, statusLevel: 0 }))(mockFetch); await composedFetch('https://api.example.com/data'); @@ -743,11 +744,11 @@ describe('applyMiddleware', () => { }); describe('Integration Tests', () => { - let mockProvider: jest.Mocked; - let mockFetch: jest.MockedFunction; + let mockProvider: Mocked; + let mockFetch: MockedFunction; beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); mockProvider = { get redirectUrl() { @@ -756,16 +757,16 @@ describe('Integration Tests', () => { get clientMetadata() { return { redirect_uris: ['http://localhost/callback'] }; }, - tokens: jest.fn(), - saveTokens: jest.fn(), - clientInformation: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - invalidateCredentials: jest.fn() + tokens: vi.fn(), + saveTokens: vi.fn(), + clientInformation: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + invalidateCredentials: vi.fn() }; - mockFetch = jest.fn(); + mockFetch = vi.fn(); }); it('should work with SSE transport pattern', async () => { @@ -783,7 +784,7 @@ describe('Integration Tests', () => { mockFetch.mockResolvedValue(response); // Use custom logger to avoid console output - const mockLogger = jest.fn(); + const mockLogger = vi.fn(); const enhancedFetch = applyMiddlewares( withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com/'), withLogging({ logger: mockLogger, statusLevel: 400 }) // Only log errors @@ -830,7 +831,7 @@ describe('Integration Tests', () => { mockFetch.mockResolvedValue(response); // Use custom logger to avoid console output - const mockLogger = jest.fn(); + const mockLogger = vi.fn(); const enhancedFetch = applyMiddlewares( withOAuth(mockProvider as OAuthClientProvider, 'https://streamable-server.example.com/'), withLogging({ @@ -891,7 +892,7 @@ describe('Integration Tests', () => { mockAuth.mockResolvedValue('AUTHORIZED'); // Use custom logger to avoid console output - const mockLogger = jest.fn(); + const mockLogger = vi.fn(); const enhancedFetch = applyMiddlewares( withOAuth(mockProvider as OAuthClientProvider, 'https://mcp-server.example.com/'), withLogging({ logger: mockLogger, statusLevel: 0 }) @@ -914,11 +915,11 @@ describe('Integration Tests', () => { }); describe('createMiddleware', () => { - let mockFetch: jest.MockedFunction; + let mockFetch: MockedFunction; beforeEach(() => { - jest.clearAllMocks(); - mockFetch = jest.fn(); + vi.clearAllMocks(); + mockFetch = vi.fn(); }); it('should create middleware with cleaner syntax', async () => { diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 9e4b73e92..0c1d078ce 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -5,6 +5,7 @@ import { SSEClientTransport } from './sse.js'; import { OAuthClientProvider, UnauthorizedError } from './auth.js'; import { OAuthTokens } from '../shared/auth.js'; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../server/auth/errors.js'; +import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; describe('SSEClientTransport', () => { let resourceServer: Server; @@ -15,7 +16,7 @@ describe('SSEClientTransport', () => { let lastServerRequest: IncomingMessage; let sendServerMessage: ((message: string) => void) | null = null; - beforeEach(done => { + beforeEach(async () => { // Reset state lastServerRequest = null as unknown as IncomingMessage; sendServerMessage = null; @@ -74,13 +75,15 @@ describe('SSEClientTransport', () => { }); // Start server on random port - resourceServer.listen(0, '127.0.0.1', () => { - const addr = resourceServer.address() as AddressInfo; - resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); - done(); + 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(); + }); }); - jest.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(async () => { @@ -88,7 +91,7 @@ describe('SSEClientTransport', () => { await resourceServer.close(); await authServer.close(); - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('connection handling', () => { @@ -262,7 +265,7 @@ describe('SSEClientTransport', () => { it('uses custom fetch implementation from options', async () => { const authToken = 'Bearer custom-token'; - const fetchWithAuth = jest.fn((url: string | URL, init?: RequestInit) => { + 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 }); @@ -310,7 +313,7 @@ describe('SSEClientTransport', () => { try { // Mock fetch for the message sending test - global.fetch = jest.fn().mockResolvedValue({ + global.fetch = vi.fn().mockResolvedValue({ ok: true }); @@ -331,7 +334,7 @@ describe('SSEClientTransport', () => { }) ); - const calledHeaders = (global.fetch as jest.Mock).mock.calls[0][1].headers; + const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; expect(calledHeaders.get('Authorization')).toBe(customHeaders.Authorization); expect(calledHeaders.get('X-Custom-Header')).toBe(customHeaders['X-Custom-Header']); expect(calledHeaders.get('content-type')).toBe('application/json'); @@ -345,7 +348,7 @@ describe('SSEClientTransport', () => { describe('auth handling', () => { const authServerMetadataUrls = ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']; - let mockAuthProvider: jest.Mocked; + let mockAuthProvider: Mocked; beforeEach(() => { mockAuthProvider = { @@ -355,13 +358,13 @@ describe('SSEClientTransport', () => { get clientMetadata() { return { redirect_uris: ['http://localhost/callback'] }; }, - clientInformation: jest.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), - tokens: jest.fn(), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - invalidateCredentials: jest.fn() + 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() }; }); @@ -1122,19 +1125,10 @@ describe('SSEClientTransport', () => { }); describe('custom fetch in auth code paths', () => { - let customFetch: jest.MockedFunction; - let globalFetchSpy: jest.SpyInstance; - let mockAuthProvider: jest.Mocked; - let resourceServerHandler: jest.Mock< - void, - [ - IncomingMessage, - ServerResponse & { - req: IncomingMessage; - } - ], - void - >; + let customFetch: MockedFunction; + let globalFetchSpy: MockInstance; + let mockAuthProvider: Mocked; + let resourceServerHandler: Mock; /** * Helper function to create a mock auth provider with configurable behavior @@ -1147,7 +1141,7 @@ describe('SSEClientTransport', () => { clientRegistered?: boolean; authorizationCode?: string; } = {} - ): jest.Mocked => { + ): Mocked => { const tokens = config.hasTokens ? { access_token: config.tokensExpired ? 'expired-token' : 'valid-token', @@ -1173,13 +1167,13 @@ describe('SSEClientTransport', () => { client_name: 'Test Client' }; }, - clientInformation: jest.fn().mockResolvedValue(clientInfo), - tokens: jest.fn().mockResolvedValue(tokens), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn().mockResolvedValue('test-verifier'), - invalidateCredentials: jest.fn() + 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() }; }; @@ -1279,12 +1273,12 @@ describe('SSEClientTransport', () => { const originalFetch = fetch; // Create custom fetch spy that delegates to real fetch - customFetch = jest.fn((url, init) => { + customFetch = vi.fn((url, init) => { return originalFetch(url.toString(), init); }); // Spy on global fetch to detect unauthorized usage - globalFetchSpy = jest.spyOn(global, 'fetch'); + globalFetchSpy = vi.spyOn(global, 'fetch'); // Create mock auth provider with default configuration mockAuthProvider = createMockAuthProvider({ @@ -1296,7 +1290,7 @@ describe('SSEClientTransport', () => { await createCustomFetchMockAuthServer(); // Set up resource server - resourceServerHandler = jest.fn( + resourceServerHandler = vi.fn( ( _req: IncomingMessage, res: ServerResponse & { @@ -1315,7 +1309,7 @@ describe('SSEClientTransport', () => { 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, res) => { + resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { if (req.url === '/') { // Return 401 to trigger auth flow res.writeHead(401, { @@ -1359,7 +1353,7 @@ describe('SSEClientTransport', () => { 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, res) => { + resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { switch (req.method) { case 'GET': if (req.url === '/') { diff --git a/src/client/sse.ts b/src/client/sse.ts index 54eac2c4a..3a51837dc 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,5 +1,5 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; -import { Transport, FetchLike } from '../shared/transport.js'; +import { Transport, FetchLike, createFetchWithInit } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; @@ -70,6 +70,7 @@ export class SSEClientTransport implements Transport { private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _fetch?: FetchLike; + private _fetchWithInit: FetchLike; private _protocolVersion?: string; onclose?: () => void; @@ -84,6 +85,7 @@ export class SSEClientTransport implements Transport { this._requestInit = opts?.requestInit; this._authProvider = opts?.authProvider; this._fetch = opts?.fetch; + this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); } private async _authThenStart(): Promise { @@ -97,7 +99,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); } catch (error) { this.onerror?.(error as Error); @@ -220,7 +222,7 @@ export class SSEClientTransport implements Transport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -260,7 +262,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 3c6a9ec4d..2799aa67e 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -2,10 +2,11 @@ import { StartSSEOptions, StreamableHTTPClientTransport, StreamableHTTPReconnect import { OAuthClientProvider, UnauthorizedError } from './auth.js'; import { JSONRPCMessage, JSONRPCRequest } from '../types.js'; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../server/auth/errors.js'; +import { type Mock, type Mocked } from 'vitest'; describe('StreamableHTTPClientTransport', () => { let transport: StreamableHTTPClientTransport; - let mockAuthProvider: jest.Mocked; + let mockAuthProvider: Mocked; beforeEach(() => { mockAuthProvider = { @@ -15,21 +16,21 @@ describe('StreamableHTTPClientTransport', () => { get clientMetadata() { return { redirect_uris: ['http://localhost/callback'] }; }, - clientInformation: jest.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), - tokens: jest.fn(), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - invalidateCredentials: jest.fn() + 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 }); - jest.spyOn(global, 'fetch'); + vi.spyOn(global, 'fetch'); }); afterEach(async () => { await transport.close().catch(() => {}); - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should send JSON-RPC messages via POST', async () => { @@ -40,7 +41,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'test-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() @@ -64,7 +65,7 @@ describe('StreamableHTTPClientTransport', () => { { jsonrpc: '2.0', method: 'test2', params: {}, id: 'id2' } ]; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/event-stream' }), @@ -94,7 +95,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'init-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) @@ -103,7 +104,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send(message); // Send a second message that should include the session ID - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() @@ -112,7 +113,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); // Check that second request included session ID header - const calls = (global.fetch as jest.Mock).mock.calls; + 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'); @@ -130,7 +131,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'init-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) @@ -140,7 +141,7 @@ describe('StreamableHTTPClientTransport', () => { expect(transport.sessionId).toBe('test-session-id'); // Now terminate the session - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers() @@ -149,7 +150,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.terminateSession(); // Verify the DELETE request was sent with the session ID - const calls = (global.fetch as jest.Mock).mock.calls; + 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'); @@ -170,7 +171,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'init-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) @@ -179,7 +180,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send(message); // Now terminate the session, but server responds with 405 - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 405, statusText: 'Method Not Allowed', @@ -197,7 +198,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'test-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', @@ -205,7 +206,7 @@ describe('StreamableHTTPClientTransport', () => { headers: new Headers() }); - const errorSpy = jest.fn(); + const errorSpy = vi.fn(); transport.onerror = errorSpy; await expect(transport.send(message)).rejects.toThrow('Error POSTing to endpoint (HTTP 404)'); @@ -226,14 +227,14 @@ describe('StreamableHTTPClientTransport', () => { id: 'test-id' }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), json: () => Promise.resolve(responseMessage) }); - const messageSpy = jest.fn(); + const messageSpy = vi.fn(); transport.onmessage = messageSpy; await transport.send(message); @@ -243,7 +244,7 @@ describe('StreamableHTTPClientTransport', () => { it('should attempt initial GET connection and handle 405 gracefully', async () => { // Mock the server not supporting GET for SSE (returning 405) - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 405, statusText: 'Method Not Allowed' @@ -263,7 +264,7 @@ describe('StreamableHTTPClientTransport', () => { ); // Verify transport still works after 405 - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() @@ -285,14 +286,14 @@ describe('StreamableHTTPClientTransport', () => { }); // Mock successful GET connection - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/event-stream' }), body: stream }); - const messageSpy = jest.fn(); + const messageSpy = vi.fn(); transport.onmessage = messageSpy; await transport.start(); @@ -322,7 +323,7 @@ describe('StreamableHTTPClientTransport', () => { }); }; - (global.fetch as jest.Mock) + (global.fetch as Mock) .mockResolvedValueOnce({ ok: true, status: 200, @@ -336,7 +337,7 @@ describe('StreamableHTTPClientTransport', () => { body: makeStream('request2') }); - const messageSpy = jest.fn(); + const messageSpy = vi.fn(); transport.onmessage = messageSpy; // Send two concurrent requests @@ -392,7 +393,7 @@ describe('StreamableHTTPClientTransport', () => { transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); // Mock fetch to verify headers sent - const fetchSpy = global.fetch as jest.Mock; + const fetchSpy = global.fetch as Mock; fetchSpy.mockReset(); fetchSpy.mockResolvedValue({ ok: true, @@ -418,7 +419,7 @@ describe('StreamableHTTPClientTransport', () => { it('should throw error when invalid content-type is received', async () => { // Clear any previous state from other tests - jest.clearAllMocks(); + vi.clearAllMocks(); // Create a fresh transport instance transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); @@ -437,10 +438,10 @@ describe('StreamableHTTPClientTransport', () => { } }); - const errorSpy = jest.fn(); + const errorSpy = vi.fn(); transport.onerror = errorSpy; - (global.fetch as jest.Mock).mockResolvedValueOnce({ + (global.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers({ 'content-type': 'text/plain' }), @@ -454,7 +455,7 @@ describe('StreamableHTTPClientTransport', () => { it('uses custom fetch implementation if provided', async () => { // Create custom fetch - const customFetch = jest + const customFetch = vi .fn() .mockResolvedValueOnce(new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } })) .mockResolvedValueOnce(new Response(null, { status: 202 })); @@ -488,7 +489,7 @@ describe('StreamableHTTPClientTransport', () => { let actualReqInit: RequestInit = {}; - (global.fetch as jest.Mock).mockImplementation(async (_url, reqInit) => { + (global.fetch as Mock).mockImplementation(async (_url, reqInit) => { actualReqInit = reqInit; return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); }); @@ -518,7 +519,7 @@ describe('StreamableHTTPClientTransport', () => { let actualReqInit: RequestInit = {}; - (global.fetch as jest.Mock).mockImplementation(async (_url, reqInit) => { + (global.fetch as Mock).mockImplementation(async (_url, reqInit) => { actualReqInit = reqInit; return new Response(null, { status: 200, headers: { 'content-type': 'text/event-stream' } }); }); @@ -576,7 +577,7 @@ describe('StreamableHTTPClientTransport', () => { id: 'test-id' }; - (global.fetch as jest.Mock) + (global.fetch as Mock) .mockResolvedValueOnce({ ok: false, status: 401, @@ -592,12 +593,104 @@ describe('StreamableHTTPClientTransport', () => { 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('./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('./auth.js'); + const authSpy = vi.spyOn(authModule, '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(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); it('should reconnect a GET-initiated notification stream that fails', async () => { // ARRANGE @@ -610,7 +703,7 @@ describe('StreamableHTTPClientTransport', () => { } }); - const errorSpy = jest.fn(); + const errorSpy = vi.fn(); transport.onerror = errorSpy; const failingStream = new ReadableStream({ @@ -619,7 +712,7 @@ describe('StreamableHTTPClientTransport', () => { } }); - const fetchMock = global.fetch as jest.Mock; + const fetchMock = global.fetch as Mock; // Mock the initial GET request, which will fail. fetchMock.mockResolvedValueOnce({ ok: true, @@ -639,7 +732,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.start(); // Trigger the GET stream directly using the internal method for a clean test. await transport['_startOrAuthSse']({}); - await jest.advanceTimersByTimeAsync(20); // Trigger reconnection timeout + await vi.advanceTimersByTimeAsync(20); // Trigger reconnection timeout // ASSERT expect(errorSpy).toHaveBeenCalledWith( @@ -664,7 +757,7 @@ describe('StreamableHTTPClientTransport', () => { } }); - const errorSpy = jest.fn(); + const errorSpy = vi.fn(); transport.onerror = errorSpy; const failingStream = new ReadableStream({ @@ -673,7 +766,7 @@ describe('StreamableHTTPClientTransport', () => { } }); - const fetchMock = global.fetch as jest.Mock; + const fetchMock = global.fetch as Mock; // Mock the POST request. It returns a streaming content-type but a failing body. fetchMock.mockResolvedValueOnce({ ok: true, @@ -694,7 +787,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.start(); // Use the public `send` method to initiate a POST that gets a stream response. await transport.send(requestMessage); - await jest.advanceTimersByTimeAsync(20); // Advance time to check for reconnections + await vi.advanceTimersByTimeAsync(20); // Advance time to check for reconnections // ASSERT expect(errorSpy).toHaveBeenCalledWith( @@ -706,6 +799,70 @@ describe('StreamableHTTPClientTransport', () => { 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 + // THE KEY ASSERTION: Fetch was called TWICE - POST then GET reconnection + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0][1]?.method).toBe('POST'); + expect(fetchMock.mock.calls[1][1]?.method).toBe('GET'); + // Verify Last-Event-ID header was sent for reconnection + const reconnectHeaders = fetchMock.mock.calls[1][1]?.headers as Headers; + expect(reconnectHeaders.get('last-event-id')).toBe('event-123'); + }); }); it('invalidates all credentials on InvalidClientError during auth', async () => { @@ -728,7 +885,7 @@ describe('StreamableHTTPClientTransport', () => { statusText: 'Unauthorized', headers: new Headers() }; - (global.fetch as jest.Mock) + (global.fetch as Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) // Resource discovery, path aware @@ -781,7 +938,7 @@ describe('StreamableHTTPClientTransport', () => { statusText: 'Unauthorized', headers: new Headers() }; - (global.fetch as jest.Mock) + (global.fetch as Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) // Resource discovery, path aware @@ -832,7 +989,7 @@ describe('StreamableHTTPClientTransport', () => { statusText: 'Unauthorized', headers: new Headers() }; - (global.fetch as jest.Mock) + (global.fetch as Mock) // Initial connection .mockResolvedValueOnce(unauthedResponse) // Resource discovery, path aware @@ -873,7 +1030,7 @@ describe('StreamableHTTPClientTransport', () => { }; // Create custom fetch - const customFetch = jest + const customFetch = vi .fn() // Initial connection .mockResolvedValueOnce(unauthedResponse) @@ -935,7 +1092,7 @@ describe('StreamableHTTPClientTransport', () => { it('uses custom fetch in finishAuth method - no global fetch fallback', async () => { // Create custom fetch - const customFetch = jest + const customFetch = vi .fn() // Protected resource metadata discovery .mockResolvedValueOnce({ @@ -1009,6 +1166,148 @@ describe('StreamableHTTPClientTransport', () => { }); }); + 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('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 = { @@ -1032,7 +1331,7 @@ describe('StreamableHTTPClientTransport', () => { headers: new Headers() }; - (global.fetch as jest.Mock) + (global.fetch as Mock) // First request - 401, triggers auth flow .mockResolvedValueOnce(unauthedResponse) // Resource discovery, path aware diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index b57013c33..9d34c7b7d 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,4 +1,4 @@ -import { Transport, FetchLike } from '../shared/transport.js'; +import { Transport, FetchLike, createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; import { auth, AuthResult, extractWWWAuthenticateParams, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; @@ -129,10 +129,13 @@ export class StreamableHTTPClientTransport implements Transport { 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 onclose?: () => void; onerror?: (error: Error) => void; @@ -145,6 +148,7 @@ export class StreamableHTTPClientTransport implements Transport { 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; } @@ -160,7 +164,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); } catch (error) { this.onerror?.(error as Error); @@ -190,7 +194,7 @@ export class StreamableHTTPClientTransport implements Transport { headers['mcp-protocol-version'] = this._protocolVersion; } - const extraHeaders = this._normalizeHeaders(this._requestInit?.headers); + const extraHeaders = normalizeHeaders(this._requestInit?.headers); return new Headers({ ...headers, @@ -200,6 +204,7 @@ export class StreamableHTTPClientTransport implements Transport { 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 @@ -246,7 +251,12 @@ export class StreamableHTTPClientTransport implements Transport { * @returns Time to wait in milliseconds before next reconnection attempt */ private _getNextReconnectionDelay(attempt: number): number { - // Access default values directly, ensuring they're never undefined + // 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; @@ -255,22 +265,8 @@ export class StreamableHTTPClientTransport implements Transport { return Math.min(initialDelay * Math.pow(growFactor, attempt), maxDelay); } - private _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) }; - } - /** - * Schedule a reconnection attempt with exponential backoff + * 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 @@ -306,6 +302,9 @@ export class StreamableHTTPClientTransport implements Transport { 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; const processStream = async () => { // this is the closest we can get to trying to catch network errors // if something happens reader will throw @@ -313,7 +312,14 @@ export class StreamableHTTPClientTransport implements Transport { // Create a pipeline: binary stream -> text decoder -> SSE parser const reader = stream .pipeThrough(new TextDecoderStream() as ReadableWritablePair) - .pipeThrough(new EventSourceParserStream()) + .pipeThrough( + new EventSourceParserStream({ + onRetry: (retryMs: number) => { + // Capture server-provided retry value for reconnection timing + this._serverRetryMs = retryMs; + } + }) + ) .getReader(); while (true) { @@ -325,6 +331,8 @@ export class StreamableHTTPClientTransport implements Transport { // 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); } @@ -340,12 +348,29 @@ export class StreamableHTTPClientTransport implements Transport { } } } + + // 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) + const canResume = isReconnectable || hasPrimingEvent; + if (canResume && 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 - if (isReconnectable && this._abortController && !this._abortController.signal.aborted) { + // Reconnect if: already reconnectable (GET stream) OR received a priming event (POST stream with event ID) + const canResume = isReconnectable || hasPrimingEvent; + if (canResume && this._abortController && !this._abortController.signal.aborted) { // Use the exponential backoff reconnection strategy try { this._scheduleReconnection( @@ -388,7 +413,7 @@ export class StreamableHTTPClientTransport implements Transport { authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError('Failed to authorize'); @@ -452,7 +477,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, - fetchFn: this._fetch + fetchFn: this._fetchWithInit }); if (result !== 'AUTHORIZED') { throw new UnauthorizedError(); @@ -464,12 +489,49 @@ export class StreamableHTTPClientTransport implements Transport { 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); + } + } + const text = await response.text().catch(() => null); throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${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) { @@ -567,4 +629,18 @@ export class StreamableHTTPClientTransport implements Transport { 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/examples/README.md b/src/examples/README.md index 1c30b8dde..0dc6867ff 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -7,11 +7,14 @@ This directory contains example implementations of MCP clients and servers using - [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) @@ -36,7 +39,7 @@ npx tsx src/examples/client/simpleStreamableHttp.ts Example client with OAuth: ```bash -npx tsx src/examples/client/simpleOAuthClient.js +npx tsx src/examples/client/simpleOAuthClient.ts ``` ### Backwards Compatible Client @@ -51,6 +54,19 @@ A client that implements backwards compatibility according to the [MCP specifica 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 @@ -105,6 +121,32 @@ A server that demonstrates server notifications using Streamable HTTP. 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 only used for testing backwards compatibility for clients. diff --git a/src/examples/client/elicitationUrlExample.ts b/src/examples/client/elicitationUrlExample.ts new file mode 100644 index 000000000..b57927e3f --- /dev/null +++ b/src/examples/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/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index fc296bc6a..21dcae012 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -6,77 +6,16 @@ import { URL } from 'node:url'; import { exec } from 'node:child_process'; import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; -import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientMetadata } from '../../shared/auth.js'; import { CallToolRequest, ListToolsRequest, CallToolResultSchema, ListToolsResultSchema } from '../../types.js'; -import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.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`; -/** - * In-memory OAuth client provider for demonstration purposes - * In production, you should persist tokens securely - */ -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 - ) { - 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; - } -} /** * Interactive MCP client with OAuth authentication * Demonstrates the complete OAuth flow with browser-based authorization @@ -88,7 +27,10 @@ class InteractiveOAuthClient { output: process.stdout }); - constructor(private serverUrl: string) {} + constructor( + private serverUrl: string, + private clientMetadataUrl?: string + ) {} /** * Prompts user for input via readline @@ -216,16 +158,20 @@ class InteractiveOAuthClient { redirect_uris: [CALLBACK_URL], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], - token_endpoint_auth_method: 'client_secret_post', - scope: 'mcp:tools' + 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()); - }); + 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...'); @@ -388,13 +334,18 @@ class InteractiveOAuthClient { * Main entry point */ async function main(): Promise { - const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL; + 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); + const client = new InteractiveOAuthClient(serverUrl, clientMetadataUrl); // Handle graceful shutdown process.on('SIGINT', () => { diff --git a/src/examples/client/simpleOAuthClientProvider.ts b/src/examples/client/simpleOAuthClientProvider.ts new file mode 100644 index 000000000..3f1932c3e --- /dev/null +++ b/src/examples/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/src/examples/client/simpleStreamableHttp.ts b/src/examples/client/simpleStreamableHttp.ts index 353861397..6627e0b83 100644 --- a/src/examples/client/simpleStreamableHttp.ts +++ b/src/examples/client/simpleStreamableHttp.ts @@ -17,7 +17,9 @@ import { ElicitRequestSchema, ResourceLink, ReadResourceRequest, - ReadResourceResultSchema + ReadResourceResultSchema, + ErrorCode, + McpError } from '../../types.js'; import { getDisplayName } from '../../shared/metadataUtils.js'; import { Ajv } from 'ajv'; @@ -60,7 +62,7 @@ function printHelp(): void { console.log(' call-tool [args] - Call a tool with optional JSON arguments'); 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 elicitation with collect-user-info tool (contact/preferences/feedback)'); + 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'); @@ -211,7 +213,7 @@ async function connect(url?: string): Promise { console.log(`Connecting to ${serverUrl}...`); try { - // Create a new client with elicitation capability + // Create a new client with form elicitation capability client = new Client( { name: 'example-client', @@ -219,7 +221,9 @@ async function connect(url?: string): Promise { }, { capabilities: { - elicitation: {} + elicitation: { + form: {} + } } } ); @@ -229,7 +233,10 @@ async function connect(url?: string): Promise { // Set up elicitation request handler with proper validation client.setRequestHandler(ElicitRequestSchema, async request => { - console.log('\n🔔 Elicitation Request Received:'); + 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('Requested Schema:'); console.log(JSON.stringify(request.params.requestedSchema, null, 2)); @@ -610,7 +617,7 @@ async function callMultiGreetTool(name: string): Promise { } async function callCollectInfoTool(infoType: string): Promise { - console.log(`Testing elicitation with collect-user-info tool (${infoType})...`); + console.log(`Testing form elicitation with collect-user-info tool (${infoType})...`); await callTool('collect-user-info', { infoType }); } diff --git a/src/examples/client/ssePollingClient.ts b/src/examples/client/ssePollingClient.ts new file mode 100644 index 000000000..ac7bba37d --- /dev/null +++ b/src/examples/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/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 0fc7daffc..6c3a740ea 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -11,7 +11,7 @@ describe('DemoInMemoryAuthProvider', () => { const createMockResponse = (): Response & { getRedirectUrl: () => string } => { let capturedRedirectUrl: string | undefined; - const mockRedirect = jest.fn().mockImplementation((url: string | number, status?: number) => { + const mockRedirect = vi.fn().mockImplementation((url: string | number, status?: number) => { if (typeof url === 'string') { capturedRedirectUrl = url; } else if (typeof status === 'string') { @@ -22,9 +22,9 @@ describe('DemoInMemoryAuthProvider', () => { const mockResponse = { redirect: mockRedirect, - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - send: jest.fn().mockReturnThis(), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), getRedirectUrl: () => { if (capturedRedirectUrl === undefined) { throw new Error('No redirect URL was captured. Ensure redirect() was called first.'); @@ -234,7 +234,7 @@ describe('DemoInMemoryAuthProvider', () => { }); it('should validate resource when validateResource is provided', async () => { - const validateResource = jest.fn().mockReturnValue(false); + const validateResource = vi.fn().mockReturnValue(false); const strictProvider = new DemoInMemoryAuthProvider(validateResource); const params: AuthorizationParams = { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ba1d3a468..1abc040ce 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -57,6 +57,23 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { 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'); } diff --git a/src/examples/server/elicitationExample.ts b/src/examples/server/elicitationFormExample.ts similarity index 95% rename from src/examples/server/elicitationExample.ts rename to src/examples/server/elicitationFormExample.ts index 6607e8cca..2e6286b71 100644 --- a/src/examples/server/elicitationExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -1,9 +1,11 @@ -// Run with: npx tsx src/examples/server/elicitationExample.ts +// Run with: npx tsx src/examples/server/elicitationFormExample.ts // -// This example demonstrates how to use elicitation to collect structured user input +// 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. -// Elicitation allows servers to request user input through the client interface +// 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 cors from 'cors'; @@ -16,7 +18,7 @@ import { isInitializeRequest } from '../../types.js'; // The validator supports format validation (email, date, etc.) if ajv-formats is installed const mcpServer = new McpServer( { - name: 'elicitation-example-server', + name: 'form-elicitation-example-server', version: '1.0.0' }, { @@ -36,8 +38,9 @@ mcpServer.registerTool( }, async () => { try { - // Request user information through elicitation + // Request user information through form elicitation const result = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Please provide your registration information:', requestedSchema: { type: 'object', @@ -123,7 +126,7 @@ mcpServer.registerTool( ); /** - * Example 2: Multi-step workflow with multiple elicitation requests + * Example 2: Multi-step workflow with multiple form elicitation requests * Demonstrates how to collect information in multiple steps */ mcpServer.registerTool( @@ -136,6 +139,7 @@ mcpServer.registerTool( 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', @@ -164,6 +168,7 @@ mcpServer.registerTool( // Step 2: Collect date and time const dateTime = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Step 2: Enter date and time', requestedSchema: { type: 'object', @@ -238,6 +243,7 @@ mcpServer.registerTool( async () => { try { const result = await mcpServer.server.elicitInput({ + mode: 'form', message: 'Please provide your shipping address:', requestedSchema: { type: 'object', @@ -441,7 +447,7 @@ async function main() { console.error('Failed to start server:', error); process.exit(1); } - console.log(`Elicitation example server is running on http://localhost:${PORT}/mcp`); + 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'); diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts new file mode 100644 index 000000000..089c6f887 --- /dev/null +++ b/src/examples/server/elicitationUrlExample.ts @@ -0,0 +1,770 @@ +// 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 { 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 = express(); +app.use(express.json()); + +// 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) { + throw new Error(`Invalid or expired token: ${await response.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 index 8b640777d..c1206d8cd 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -2,7 +2,7 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import cors from 'cors'; diff --git a/src/examples/server/mcpServerOutputSchema.ts b/src/examples/server/mcpServerOutputSchema.ts index 5d1cab0bd..7ef9f6227 100644 --- a/src/examples/server/mcpServerOutputSchema.ts +++ b/src/examples/server/mcpServerOutputSchema.ts @@ -6,7 +6,7 @@ import { McpServer } from '../../server/mcp.js'; import { StdioServerTransport } from '../../server/stdio.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const server = new McpServer({ name: 'mcp-output-schema-high-level-example', diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index b99334369..e07f36010 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { SSEServerTransport } from '../../server/sse.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { CallToolResult } from '../../types.js'; /** diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index f71e5db6c..464ea2623 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; import cors from 'cors'; diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 6c970bdd1..33568bc82 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; +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'; @@ -111,11 +111,11 @@ const getServer = () => { }; } ); - // Register a tool that demonstrates elicitation (user input collection) + // 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 elicitation', + 'A tool that collects user information through form elicitation', { infoType: z.enum(['contact', 'preferences', 'feedback']).describe('Type of information to collect') }, @@ -216,6 +216,7 @@ const getServer = () => { try { // Use the underlying server instance to elicit input from the client const result = await server.server.elicitInput({ + mode: 'form', message, requestedSchema }); diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 50e2e5125..8eb3724c3 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { SSEServerTransport } from '../../server/sse.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; import cors from 'cors'; diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts new file mode 100644 index 000000000..8bb8cfbc9 --- /dev/null +++ b/src/examples/server/ssePollingExample.ts @@ -0,0 +1,150 @@ +/** + * 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 + * - Calls `closeSSEStream()` to gracefully disconnect clients mid-operation + * + * Run with: npx tsx src/examples/server/ssePollingExample.ts + * Test with: curl or the MCP Inspector + */ +import express, { Request, Response } from 'express'; +import { randomUUID } from 'node:crypto'; +import { McpServer } from '../../server/mcp.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: {} } + } +); + +// Track active transports by session ID for closeSSEStream access +const transports = new Map(); + +// 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 retryInterval + const transport = transports.get(extra.sessionId!); + if (transport) { + console.log(`[${extra.sessionId}] Closing SSE stream to trigger client polling...`); + transport.closeSSEStream(extra.requestId); + } + + // 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 = express(); +app.use(cors()); + +// Create event store for resumability +const eventStore = new InMemoryEventStore(); + +// Handle all MCP requests - use express.json() only for this route +app.all('/mcp', express.json(), 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, // Client should reconnect after 2 seconds + 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/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index ad5a01bdc..c198dc0ec 100644 --- a/src/examples/server/toolWithSampleServer.ts +++ b/src/examples/server/toolWithSampleServer.ts @@ -2,7 +2,7 @@ import { McpServer } from '../../server/mcp.js'; import { StdioServerTransport } from '../../server/stdio.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; const mcpServer = new McpServer({ name: 'tools-with-sample-server', @@ -33,13 +33,12 @@ mcpServer.registerTool( maxTokens: 500 }); + const contents = Array.isArray(response.content) ? response.content : [response.content]; return { - content: [ - { - type: 'text', - text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' - } - ] + content: contents.map(content => ({ + type: 'text', + text: content.type === 'text' ? content.text : 'Unable to generate summary' + })) }; } ); diff --git a/src/integration-tests/process-cleanup.test.ts b/src/integration-tests/process-cleanup.test.ts index 8c7c42b46..e90ec7e24 100644 --- a/src/integration-tests/process-cleanup.test.ts +++ b/src/integration-tests/process-cleanup.test.ts @@ -2,7 +2,7 @@ import { Server } from '../server/index.js'; import { StdioServerTransport } from '../server/stdio.js'; describe('Process cleanup', () => { - jest.setTimeout(5000); // 5 second timeout + vi.setConfig({ testTimeout: 5000 }); // 5 second timeout it('should exit cleanly after closing transport', async () => { const server = new Server( diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 629b01519..3294df4d4 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -12,346 +12,349 @@ import { ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; -import { z } from 'zod'; - -describe('Streamable HTTP Transport Session Management', () => { - // Function to set up the server with optional session management - async function setupServer(withSessionManagement: boolean) { - const server: Server = createServer(); - const mcpServer = new McpServer( - { name: 'test-server', version: '1.0.0' }, - { - capabilities: { - logging: {}, - tools: {}, - resources: {}, - prompts: {} - } - } - ); - - // Add a simple resource - mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({ - contents: [ - { - uri: '/test', - text: 'This is a test resource content' - } - ] - })); - - mcpServer.prompt('test-prompt', 'A test prompt', async () => ({ - messages: [ +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; + +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + describe('Streamable HTTP Transport Session Management', () => { + // Function to set up the server with optional session management + async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, { - role: 'user', - content: { - type: 'text', - text: 'This is a test prompt' + capabilities: { + logging: {}, + tools: {}, + resources: {}, + prompts: {} } } - ] - })); - - mcpServer.tool( - 'greet', - 'A simple greeting tool', - { - name: z.string().describe('Name to greet').default('World') - }, - async ({ name }) => { - return { - content: [{ type: 'text', text: `Hello, ${name}!` }] - }; - } - ); - - // Create transport with or without session management - const serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: withSessionManagement - ? () => randomUUID() // With session management, generate UUID - : undefined // Without session management, return undefined - }); - - await mcpServer.connect(serverTransport); - - server.on('request', async (req, res) => { - await serverTransport.handleRequest(req, res); - }); - - // Start the server on a random port - const baseUrl = await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - return { server, mcpServer, serverTransport, baseUrl }; - } - - describe('Stateless Mode', () => { - let server: Server; - let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; - let baseUrl: URL; - - beforeEach(async () => { - const setup = await setupServer(false); - server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; - baseUrl = setup.baseUrl; - }); - - afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - server.close(); - }); - - it('should support multiple client connections', async () => { - // Create and connect a client - const client1 = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const transport1 = new StreamableHTTPClientTransport(baseUrl); - await client1.connect(transport1); + ); - // Verify that no session ID was set - expect(transport1.sessionId).toBeUndefined(); + // Add a simple resource + mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({ + contents: [ + { + uri: '/test', + text: 'This is a test resource content' + } + ] + })); + + mcpServer.prompt('test-prompt', 'A test prompt', async () => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + } + ] + })); - // List available tools - await client1.request( + mcpServer.tool( + 'greet', + 'A simple greeting tool', { - method: 'tools/list', - params: {} + name: z.string().describe('Name to greet').default('World') }, - ListToolsResultSchema + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } ); - const client2 = new Client({ - name: 'test-client', - version: '1.0.0' + // Create transport with or without session management + const serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: withSessionManagement + ? () => randomUUID() // With session management, generate UUID + : undefined // Without session management, return undefined }); - const transport2 = new StreamableHTTPClientTransport(baseUrl); - await client2.connect(transport2); + await mcpServer.connect(serverTransport); - // Verify that no session ID was set - expect(transport2.sessionId).toBeUndefined(); - - // List available tools - await client2.request( - { - method: 'tools/list', - params: {} - }, - ListToolsResultSchema - ); - }); - it('should operate without session management', async () => { - // Create and connect a client - const client = new Client({ - name: 'test-client', - version: '1.0.0' + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Verify that no session ID was set - expect(transport.sessionId).toBeUndefined(); - - // List available tools - const toolsResult = await client.request( - { - method: 'tools/list', - params: {} - }, - ListToolsResultSchema - ); - - // Verify tools are accessible - expect(toolsResult.tools).toContainEqual( - expect.objectContaining({ - name: 'greet' - }) - ); - - // List available resources - const resourcesResult = await client.request( - { - method: 'resources/list', - params: {} - }, - ListResourcesResultSchema - ); - - // Verify resources result structure - expect(resourcesResult).toHaveProperty('resources'); + // Start the server on a random port + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); - // List available prompts - const promptsResult = await client.request( - { - method: 'prompts/list', - params: {} - }, - ListPromptsResultSchema - ); + return { server, mcpServer, serverTransport, baseUrl }; + } + + describe('Stateless Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(false); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); - // Verify prompts result structure - expect(promptsResult).toHaveProperty('prompts'); - expect(promptsResult.prompts).toContainEqual( - expect.objectContaining({ - name: 'test-prompt' - }) - ); + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); - // Call the greeting tool - const greetingResult = await client.request( - { - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Stateless Transport' + it('should support multiple client connections', async () => { + // Create and connect a client + const client1 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + + // Verify that no session ID was set + expect(transport1.sessionId).toBeUndefined(); + + // List available tools + await client1.request( + { + method: 'tools/list', + params: {} + }, + ListToolsResultSchema + ); + + const client2 = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport2 = new StreamableHTTPClientTransport(baseUrl); + await client2.connect(transport2); + + // Verify that no session ID was set + expect(transport2.sessionId).toBeUndefined(); + + // List available tools + await client2.request( + { + method: 'tools/list', + params: {} + }, + ListToolsResultSchema + ); + }); + it('should operate without session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that no session ID was set + expect(transport.sessionId).toBeUndefined(); + + // List available tools + const toolsResult = await client.request( + { + method: 'tools/list', + params: {} + }, + ListToolsResultSchema + ); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual( + expect.objectContaining({ + name: 'greet' + }) + ); + + // List available resources + const resourcesResult = await client.request( + { + method: 'resources/list', + params: {} + }, + ListResourcesResultSchema + ); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request( + { + method: 'prompts/list', + params: {} + }, + ListPromptsResultSchema + ); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual( + expect.objectContaining({ + name: 'test-prompt' + }) + ); + + // Call the greeting tool + const greetingResult = await client.request( + { + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateless Transport' + } } - } - }, - CallToolResultSchema - ); + }, + CallToolResultSchema + ); - // Verify tool result - expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]); + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]); - // Clean up - await transport.close(); - }); - - it('should set protocol version after connecting', async () => { - // Create and connect a client - const client = new Client({ - name: 'test-client', - version: '1.0.0' + // Clean up + await transport.close(); }); - const transport = new StreamableHTTPClientTransport(baseUrl); - - // Verify protocol version is not set before connecting - expect(transport.protocolVersion).toBeUndefined(); + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - await client.connect(transport); + const transport = new StreamableHTTPClientTransport(baseUrl); - // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); - // Clean up - await transport.close(); - }); - }); + await client.connect(transport); - describe('Stateful Mode', () => { - let server: Server; - let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; - let baseUrl: URL; - - beforeEach(async () => { - const setup = await setupServer(true); - server = setup.server; - mcpServer = setup.mcpServer; - serverTransport = setup.serverTransport; - baseUrl = setup.baseUrl; - }); + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - server.close(); + // Clean up + await transport.close(); + }); }); - it('should operate with session management', async () => { - // Create and connect a client - const client = new Client({ - name: 'test-client', - version: '1.0.0' + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); - - // Verify that a session ID was set - expect(transport.sessionId).toBeDefined(); - expect(typeof transport.sessionId).toBe('string'); - - // List available tools - const toolsResult = await client.request( - { - method: 'tools/list', - params: {} - }, - ListToolsResultSchema - ); - - // Verify tools are accessible - expect(toolsResult.tools).toContainEqual( - expect.objectContaining({ - name: 'greet' - }) - ); - - // List available resources - const resourcesResult = await client.request( - { - method: 'resources/list', - params: {} - }, - ListResourcesResultSchema - ); - - // Verify resources result structure - expect(resourcesResult).toHaveProperty('resources'); - - // List available prompts - const promptsResult = await client.request( - { - method: 'prompts/list', - params: {} - }, - ListPromptsResultSchema - ); - - // Verify prompts result structure - expect(promptsResult).toHaveProperty('prompts'); - expect(promptsResult.prompts).toContainEqual( - expect.objectContaining({ - name: 'test-prompt' - }) - ); + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); - // Call the greeting tool - const greetingResult = await client.request( - { - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Stateful Transport' + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request( + { + method: 'tools/list', + params: {} + }, + ListToolsResultSchema + ); + + // Verify tools are accessible + expect(toolsResult.tools).toContainEqual( + expect.objectContaining({ + name: 'greet' + }) + ); + + // List available resources + const resourcesResult = await client.request( + { + method: 'resources/list', + params: {} + }, + ListResourcesResultSchema + ); + + // Verify resources result structure + expect(resourcesResult).toHaveProperty('resources'); + + // List available prompts + const promptsResult = await client.request( + { + method: 'prompts/list', + params: {} + }, + ListPromptsResultSchema + ); + + // Verify prompts result structure + expect(promptsResult).toHaveProperty('prompts'); + expect(promptsResult.prompts).toContainEqual( + expect.objectContaining({ + name: 'test-prompt' + }) + ); + + // Call the greeting tool + const greetingResult = await client.request( + { + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Stateful Transport' + } } - } - }, - CallToolResultSchema - ); + }, + CallToolResultSchema + ); - // Verify tool result - expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]); + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]); - // Clean up - await transport.close(); + // Clean up + await transport.close(); + }); }); }); }); diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index 224d8e382..3c357d171 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -6,265 +6,264 @@ import { StreamableHTTPClientTransport } from '../client/streamableHttp.js'; import { McpServer } from '../server/mcp.js'; import { StreamableHTTPServerTransport } from '../server/streamableHttp.js'; import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js'; -import { z } from 'zod'; import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; - -describe('Transport resumability', () => { - let server: Server; - let mcpServer: McpServer; - let serverTransport: StreamableHTTPServerTransport; - let baseUrl: URL; - let eventStore: InMemoryEventStore; - - beforeEach(async () => { - // Create event store for resumability - eventStore = new InMemoryEventStore(); - - // Create a simple MCP server - mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - // Add a simple notification tool that completes quickly - mcpServer.tool( - 'send-notification', - 'Sends a single notification', - { - message: z.string().describe('Message to send').default('Test notification') - }, - async ({ message }, { sendNotification }) => { - // Send notification immediately - await sendNotification({ - method: 'notifications/message', - params: { - level: 'info', - data: message - } - }); - - return { - content: [{ type: 'text', text: 'Notification sent' }] - }; - } - ); - - // Add a long-running tool that sends multiple notifications - mcpServer.tool( - 'run-notifications', - 'Sends multiple notifications over time', - { - count: z.number().describe('Number of notifications to send').default(10), - interval: z.number().describe('Interval between notifications in ms').default(50) - }, - async ({ count, interval }, { sendNotification }) => { - // Send notifications at specified intervals - for (let i = 0; i < count; i++) { +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; + +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + // Add a simple notification tool that completes quickly + mcpServer.tool( + 'send-notification', + 'Sends a single notification', + { + message: z.string().describe('Message to send').default('Test notification') + }, + async ({ message }, { sendNotification }) => { + // Send notification immediately await sendNotification({ method: 'notifications/message', params: { level: 'info', - data: `Notification ${i + 1} of ${count}` + data: message } }); - // Wait for the specified interval before sending next notification - if (i < count - 1) { - await new Promise(resolve => setTimeout(resolve, interval)); - } + return { + content: [{ type: 'text', text: 'Notification sent' }] + }; } + ); + + // Add a long-running tool that sends multiple notifications + mcpServer.tool( + 'run-notifications', + 'Sends multiple notifications over time', + { + count: z.number().describe('Number of notifications to send').default(10), + interval: z.number().describe('Interval between notifications in ms').default(50) + }, + async ({ count, interval }, { sendNotification }) => { + // Send notifications at specified intervals + for (let i = 0; i < count; i++) { + await sendNotification({ + method: 'notifications/message', + params: { + level: 'info', + data: `Notification ${i + 1} of ${count}` + } + }); + + // Wait for the specified interval before sending next notification + if (i < count - 1) { + await new Promise(resolve => setTimeout(resolve, interval)); + } + } - return { - content: [{ type: 'text', text: `Sent ${count} notifications` }] - }; - } - ); + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); - // Create a transport with the event store - serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore - }); + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); - // Connect the transport to the MCP server - await mcpServer.connect(serverTransport); + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); - // Create and start an HTTP server - server = createServer(async (req, res) => { - await serverTransport.handleRequest(req, res); - }); + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); - // Start the server on a random port - baseUrl = await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); + // Start the server on a random port + baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); }); }); - }); - - afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - server.close(); - }); - it('should store session ID when client connects', async () => { - // Create and connect a client - const client = new Client({ - name: 'test-client', - version: '1.0.0' + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); }); - const transport = new StreamableHTTPClientTransport(baseUrl); - await client.connect(transport); + it('should store session ID when client connects', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - // Verify session ID was generated - expect(transport.sessionId).toBeDefined(); + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); - // Clean up - await transport.close(); - }); + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); - it('should have session ID functionality', async () => { - // The ability to store a session ID when connecting - const client = new Client({ - name: 'test-client-reconnection', - version: '1.0.0' + // Clean up + await transport.close(); }); - const transport = new StreamableHTTPClientTransport(baseUrl); + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); - // Make sure the client can connect and get a session ID - await client.connect(transport); - expect(transport.sessionId).toBeDefined(); + const transport = new StreamableHTTPClientTransport(baseUrl); - // Clean up - await transport.close(); - }); + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); - // This test demonstrates the capability to resume long-running tools - // across client disconnection/reconnection - it('should resume long-running notifications with lastEventId', async () => { - // Create unique client ID for this test - const clientTitle = 'test-client-long-running'; - const notifications = []; - let lastEventId: string | undefined; - - // Create first client - const client1 = new Client({ - title: clientTitle, - name: 'test-client', - version: '1.0.0' + // Clean up + await transport.close(); }); - // Set up notification handler for first client - client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - if (notification.method === 'notifications/message') { - notifications.push(notification.params); - } - }); + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientTitle = 'test-client-long-running'; + const notifications = []; + let lastEventId: string | undefined; + + // Create first client + const client1 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); - // Connect first client - const transport1 = new StreamableHTTPClientTransport(baseUrl); - await client1.connect(transport1); - const sessionId = transport1.sessionId; - expect(sessionId).toBeDefined(); + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); - // Start a long-running notification stream with tracking of lastEventId - const onLastEventIdUpdate = jest.fn((eventId: string) => { - lastEventId = eventId; - }); - expect(lastEventId).toBeUndefined(); - // Start the notification tool with event tracking using request - const toolPromise = client1.request( - { - method: 'tools/call', - params: { - name: 'run-notifications', - arguments: { - count: 3, - interval: 10 + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + const sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = vi.fn((eventId: string) => { + lastEventId = eventId; + }); + expect(lastEventId).toBeUndefined(); + // Start the notification tool with event tracking using request + const toolPromise = client1.request( + { + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 3, + interval: 10 + } } + }, + CallToolResultSchema, + { + resumptionToken: lastEventId, + onresumptiontoken: onLastEventIdUpdate } - }, - CallToolResultSchema, - { - resumptionToken: lastEventId, - onresumptiontoken: onLastEventIdUpdate - } - ); - - // Wait for some notifications to arrive (not all) - shorter wait time - await new Promise(resolve => setTimeout(resolve, 20)); - - // Verify we received some notifications and lastEventId was updated - expect(notifications.length).toBeGreaterThan(0); - expect(notifications.length).toBeLessThan(4); - expect(onLastEventIdUpdate).toHaveBeenCalled(); - expect(lastEventId).toBeDefined(); - - // Disconnect first client without waiting for completion - // When we close the connection, it will cause a ConnectionClosed error for - // any in-progress requests, which is expected behavior - await transport1.close(); - // Save the promise so we can catch it after closing - const catchPromise = toolPromise.catch(err => { - // This error is expected - the connection was intentionally closed - if (err?.code !== -32000) { - // ConnectionClosed error code - console.error('Unexpected error type during transport close:', err); + ); + + // Fix for node 18 test failures, allow some time for notifications to arrive + const maxWaitTime = 2000; // 2 seconds max wait + const pollInterval = 10; // Check every 10ms + const startTime = Date.now(); + while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) { + // Wait for some notifications to arrive (not all) - shorter wait time + await new Promise(resolve => setTimeout(resolve, pollInterval)); } - }); - // Add a short delay to ensure clean disconnect before reconnecting - await new Promise(resolve => setTimeout(resolve, 10)); + // Verify we received some notifications and lastEventId was updated + expect(notifications.length).toBeGreaterThan(0); + expect(notifications.length).toBeLessThan(4); + expect(onLastEventIdUpdate).toHaveBeenCalled(); + expect(lastEventId).toBeDefined(); + + // Disconnect first client without waiting for completion + // When we close the connection, it will cause a ConnectionClosed error for + // any in-progress requests, which is expected behavior + await transport1.close(); + // Save the promise so we can catch it after closing + const catchPromise = toolPromise.catch(err => { + // This error is expected - the connection was intentionally closed + if (err?.code !== -32000) { + // ConnectionClosed error code + console.error('Unexpected error type during transport close:', err); + } + }); - // Wait for the rejection to be handled - await catchPromise; + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); - // Create second client with same client ID - const client2 = new Client({ - title: clientTitle, - name: 'test-client', - version: '1.0.0' - }); + // Wait for the rejection to be handled + await catchPromise; - // Set up notification handler for second client - client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - if (notification.method === 'notifications/message') { - notifications.push(notification.params); - } - }); + // Create second client with same client ID + const client2 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); - // Connect second client with same session ID - const transport2 = new StreamableHTTPClientTransport(baseUrl, { - sessionId - }); - await client2.connect(transport2); - - // Resume the notification stream using lastEventId - // This is the key part - we're resuming the same long-running tool using lastEventId - await client2.request( - { - method: 'tools/call', - params: { - name: 'run-notifications', - arguments: { - count: 1, - interval: 5 - } + // Track replayed notifications separately + const replayedNotifications: unknown[] = []; + client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + if (notification.method === 'notifications/message') { + replayedNotifications.push(notification.params); } - }, - CallToolResultSchema, - { - resumptionToken: lastEventId, // Pass the lastEventId from the previous session - onresumptiontoken: onLastEventIdUpdate - } - ); + }); + + // Connect second client with same session ID + const transport2 = new StreamableHTTPClientTransport(baseUrl, { + sessionId + }); + await client2.connect(transport2); - // Verify we eventually received at leaset a few motifications - expect(notifications.length).toBeGreaterThan(1); + // Resume GET SSE stream with Last-Event-ID to replay missed events + // Per spec, resumption uses GET with Last-Event-ID header + await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate }); - // Clean up - await transport2.close(); + // Wait for replayed events to arrive via SSE + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify the test infrastructure worked - we received notifications in first session + // and captured the lastEventId for potential replay + expect(notifications.length).toBeGreaterThan(0); + expect(lastEventId).toBeDefined(); + + // Clean up + await transport2.close(); + }); }); }); diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 51ce111a0..8762d40d7 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -220,7 +220,7 @@ describe('Authorization Handler', () => { describe('Resource parameter validation', () => { it('propagates resource parameter', async () => { - const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); + const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize'); const response = await supertest(app).get('/authorize').query({ client_id: 'valid-client', diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index ef15770b9..dcb6c03ec 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -1,5 +1,5 @@ import { RequestHandler } from 'express'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import express from 'express'; import { OAuthServerProvider } from '../provider.js'; import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit'; diff --git a/src/server/auth/handlers/register.test.ts b/src/server/auth/handlers/register.test.ts index c4821431a..85ddca162 100644 --- a/src/server/auth/handlers/register.test.ts +++ b/src/server/auth/handlers/register.test.ts @@ -3,6 +3,7 @@ import { OAuthRegisteredClientsStore } from '../clients.js'; import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../shared/auth.js'; import express from 'express'; import supertest from 'supertest'; +import { MockInstance } from 'vitest'; describe('Client Registration Handler', () => { // Mock client store with registration support @@ -45,7 +46,7 @@ describe('Client Registration Handler', () => { describe('Request handling', () => { let app: express.Express; - let spyRegisterClient: jest.SpyInstance; + let spyRegisterClient: MockInstance; beforeEach(() => { // Setup express app with registration handler @@ -58,7 +59,7 @@ describe('Client Registration Handler', () => { app.use('/register', clientRegistrationHandler(options)); // Spy on the registerClient method - spyRegisterClient = jest.spyOn(mockClientStoreWithRegistration, 'registerClient'); + spyRegisterClient = vi.spyOn(mockClientStoreWithRegistration, 'registerClient'); }); afterEach(() => { diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts index 594b689e9..35fad72fd 100644 --- a/src/server/auth/handlers/revoke.test.ts +++ b/src/server/auth/handlers/revoke.test.ts @@ -6,6 +6,7 @@ import express, { Response } from 'express'; import supertest from 'supertest'; import { AuthInfo } from '../types.js'; import { InvalidTokenError } from '../errors.js'; +import { MockInstance } from 'vitest'; describe('Revocation Handler', () => { // Mock client data @@ -130,7 +131,7 @@ describe('Revocation Handler', () => { describe('Request handling', () => { let app: express.Express; - let spyRevokeToken: jest.SpyInstance; + let spyRevokeToken: MockInstance; beforeEach(() => { // Setup express app with revocation handler @@ -139,7 +140,7 @@ describe('Revocation Handler', () => { app.use('/revoke', revocationHandler(options)); // Spy on the revokeToken method - spyRevokeToken = jest.spyOn(mockProviderWithRevocation, 'revokeToken'); + spyRevokeToken = vi.spyOn(mockProviderWithRevocation, 'revokeToken'); }); afterEach(() => { diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index e0338f030..f83b961ae 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -8,10 +8,11 @@ import * as pkceChallenge from 'pkce-challenge'; import { InvalidGrantError, InvalidTokenError } from '../errors.js'; import { AuthInfo } from '../types.js'; import { ProxyOAuthServerProvider } from '../providers/proxyProvider.js'; +import { type Mock } from 'vitest'; // Mock pkce-challenge -jest.mock('pkce-challenge', () => ({ - verifyChallenge: jest.fn().mockImplementation(async (verifier, challenge) => { +vi.mock('pkce-challenge', () => ({ + verifyChallenge: vi.fn().mockImplementation(async (verifier, challenge) => { return verifier === 'valid_verifier' && challenge === 'mock_challenge'; }) })); @@ -111,7 +112,7 @@ describe('Token Handler', () => { }; // Mock PKCE verification - (pkceChallenge.verifyChallenge as jest.Mock).mockImplementation(async (verifier: string, challenge: string) => { + (pkceChallenge.verifyChallenge as Mock).mockImplementation(async (verifier: string, challenge: string) => { return verifier === 'valid_verifier' && challenge === 'mock_challenge'; }); @@ -214,7 +215,7 @@ describe('Token Handler', () => { it('verifies code_verifier against challenge', async () => { // Setup invalid verifier - (pkceChallenge.verifyChallenge as jest.Mock).mockResolvedValueOnce(false); + (pkceChallenge.verifyChallenge as Mock).mockResolvedValueOnce(false); const response = await supertest(app).post('/token').type('form').send({ client_id: 'valid-client', @@ -243,7 +244,7 @@ describe('Token Handler', () => { }); it('returns tokens for valid code exchange', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + const mockExchangeCode = vi.spyOn(mockProvider, 'exchangeAuthorizationCode'); const response = await supertest(app).post('/token').type('form').send({ client_id: 'valid-client', client_secret: 'valid-secret', @@ -294,7 +295,7 @@ describe('Token Handler', () => { const originalFetch = global.fetch; try { - global.fetch = jest.fn().mockResolvedValue({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockTokens) }); @@ -348,7 +349,7 @@ describe('Token Handler', () => { const originalFetch = global.fetch; try { - global.fetch = jest.fn().mockResolvedValue({ + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockTokens) }); @@ -426,7 +427,7 @@ describe('Token Handler', () => { }); it('returns new tokens for valid refresh token', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + const mockExchangeRefresh = vi.spyOn(mockProvider, 'exchangeRefreshToken'); const response = await supertest(app).post('/token').type('form').send({ client_id: 'valid-client', client_secret: 'valid-secret', diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index c387ff7bf..75a20329d 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import express, { RequestHandler } from 'express'; import { OAuthServerProvider } from '../provider.js'; import cors from 'cors'; diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 5790a0eb0..03a65da39 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -1,11 +1,12 @@ import { Request, Response } from 'express'; +import { Mock } from 'vitest'; import { requireBearerAuth } from './bearerAuth.js'; import { AuthInfo } from '../types.js'; import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../errors.js'; import { OAuthTokenVerifier } from '../provider.js'; // Mock verifier -const mockVerifyAccessToken = jest.fn(); +const mockVerifyAccessToken = vi.fn(); const mockVerifier: OAuthTokenVerifier = { verifyAccessToken: mockVerifyAccessToken }; @@ -13,23 +14,23 @@ const mockVerifier: OAuthTokenVerifier = { describe('requireBearerAuth middleware', () => { let mockRequest: Partial; let mockResponse: Partial; - let nextFunction: jest.Mock; + let nextFunction: Mock; beforeEach(() => { mockRequest = { headers: {} }; mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - set: jest.fn().mockReturnThis() + status: vi.fn().mockReturnThis(), + json: vi.fn(), + set: vi.fn().mockReturnThis() }; - nextFunction = jest.fn(); - jest.spyOn(console, 'error').mockImplementation(() => {}); + nextFunction = vi.fn(); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); it('should call next when token is valid', async () => { @@ -315,6 +316,71 @@ describe('requireBearerAuth middleware', () => { expect(nextFunction).not.toHaveBeenCalled(); }); + describe('with requiredScopes in WWW-Authenticate header', () => { + it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => { + mockRequest.headers = {}; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"' + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read'] + }; + mockVerifyAccessToken.mockResolvedValue(authInfo); + + mockRequest.headers = { + authorization: 'Bearer valid-token' + }; + + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['read', 'write'] + }); + + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"' + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + + it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => { + mockRequest.headers = {}; + + const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; + const middleware = requireBearerAuth({ + verifier: mockVerifier, + requiredScopes: ['admin'], + resourceMetadataUrl + }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + `Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"` + ); + expect(nextFunction).not.toHaveBeenCalled(); + }); + }); + describe('with resourceMetadataUrl', () => { const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource'; @@ -415,7 +481,7 @@ describe('requireBearerAuth middleware', () => { expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( 'WWW-Authenticate', - `Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"` + `Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"` ); expect(nextFunction).not.toHaveBeenCalled(); }); diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 363fd7a42..dac653086 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -71,17 +71,23 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad 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) { - const wwwAuthValue = resourceMetadataUrl - ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` - : `Bearer error="${error.errorCode}", error_description="${error.message}"`; - res.set('WWW-Authenticate', wwwAuthValue); + res.set('WWW-Authenticate', buildWwwAuthHeader(error.errorCode, error.message)); res.status(401).json(error.toResponseObject()); } else if (error instanceof InsufficientScopeError) { - const wwwAuthValue = resourceMetadataUrl - ? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"` - : `Bearer error="${error.errorCode}", error_description="${error.message}"`; - res.set('WWW-Authenticate', wwwAuthValue); + 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()); diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 9969b8724..52611a660 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { OAuthClientInformationFull } from '../../../shared/auth.js'; diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 97069ca6b..ee008f5a3 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -5,6 +5,7 @@ import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js import { ServerError } from '../errors.js'; import { InvalidTokenError } from '../errors.js'; import { InsufficientScopeError } from '../errors.js'; +import { type Mock } from 'vitest'; describe('Proxy OAuth Server Provider', () => { // Mock client data @@ -16,12 +17,12 @@ describe('Proxy OAuth Server Provider', () => { // Mock response object const mockResponse = { - redirect: jest.fn() + redirect: vi.fn() } as unknown as Response; // Mock provider functions - const mockVerifyToken = jest.fn(); - const mockGetClient = jest.fn(); + const mockVerifyToken = vi.fn(); + const mockGetClient = vi.fn(); // Base provider options const baseOptions: ProxyOptions = { @@ -41,7 +42,7 @@ describe('Proxy OAuth Server Provider', () => { beforeEach(() => { provider = new ProxyOAuthServerProvider(baseOptions); originalFetch = global.fetch; - global.fetch = jest.fn(); + global.fetch = vi.fn(); // Setup mock implementations mockVerifyToken.mockImplementation(async (token: string) => { @@ -66,7 +67,7 @@ describe('Proxy OAuth Server Provider', () => { // Add helper function for failed responses const mockFailedResponse = () => { - (global.fetch as jest.Mock).mockImplementation(() => + (global.fetch as Mock).mockImplementation(() => Promise.resolve({ ok: false, status: 400 @@ -76,7 +77,7 @@ describe('Proxy OAuth Server Provider', () => { afterEach(() => { global.fetch = originalFetch; - jest.clearAllMocks(); + vi.clearAllMocks(); }); describe('authorization', () => { @@ -116,7 +117,7 @@ describe('Proxy OAuth Server Provider', () => { }; beforeEach(() => { - (global.fetch as jest.Mock).mockImplementation(() => + (global.fetch as Mock).mockImplementation(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockTokenResponse) @@ -182,7 +183,7 @@ describe('Proxy OAuth Server Provider', () => { it('handles authorization code exchange without resource parameter', async () => { const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const fetchCall = (global.fetch as Mock).mock.calls[0]; const body = fetchCall[1].body as string; expect(body).not.toContain('resource='); expect(tokens).toEqual(mockTokenResponse); @@ -233,7 +234,7 @@ describe('Proxy OAuth Server Provider', () => { redirect_uris: ['https://new-client.com/callback'] }; - (global.fetch as jest.Mock).mockImplementation(() => + (global.fetch as Mock).mockImplementation(() => Promise.resolve({ ok: true, json: () => Promise.resolve(newClient) @@ -268,7 +269,7 @@ describe('Proxy OAuth Server Provider', () => { describe('token revocation', () => { it('revokes token', async () => { - (global.fetch as jest.Mock).mockImplementation(() => + (global.fetch as Mock).mockImplementation(() => Promise.resolve({ ok: true }) diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index f2091bcbe..ae280286b 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -213,7 +213,7 @@ describe('MCP Auth Router', () => { expect(response.body.response_types_supported).toEqual(['code']); expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); expect(response.body.code_challenge_methods_supported).toEqual(['S256']); - expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']); expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); // Verify optional fields @@ -279,11 +279,11 @@ describe('MCP Auth Router', () => { issuerUrl: new URL('https://auth.example.com/') }; app.use(mcpAuthRouter(options)); - jest.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); it('routes to authorization endpoint', async () => { @@ -301,8 +301,8 @@ describe('MCP Auth Router', () => { it('routes to token endpoint', async () => { // Setup verifyChallenge mock for token handler - jest.mock('pkce-challenge', () => ({ - verifyChallenge: jest.fn().mockResolvedValue(true) + vi.mock('pkce-challenge', () => ({ + verifyChallenge: vi.fn().mockResolvedValue(true) })); const response = await supertest(app).post('/token').type('form').send({ diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index dc0a85a33..5229c4df8 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -92,7 +92,7 @@ export const createOAuthMetadata = (options: { code_challenge_methods_supported: ['S256'], token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, - token_endpoint_auth_methods_supported: ['client_secret_post'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], grant_types_supported: ['authorization_code', 'refresh_token'], scopes_supported: options.scopesSupported, diff --git a/src/server/completable.test.ts b/src/server/completable.test.ts index b5effc272..e0d2aba99 100644 --- a/src/server/completable.test.ts +++ b/src/server/completable.test.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; -import { completable } from './completable.js'; +import { completable, getCompleter } from './completable.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; -describe('completable', () => { +describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; it('preserves types and values of underlying schema', () => { const baseSchema = z.string(); const schema = completable(baseSchema, () => []); @@ -14,27 +15,35 @@ describe('completable', () => { const completions = ['foo', 'bar', 'baz']; const schema = completable(z.string(), () => completions); - expect(await schema._def.complete('')).toEqual(completions); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); }); it('allows async completion functions', async () => { const completions = ['foo', 'bar', 'baz']; const schema = completable(z.string(), async () => completions); - expect(await schema._def.complete('')).toEqual(completions); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); }); it('passes current value to completion function', async () => { const schema = completable(z.string(), value => [value + '!']); - expect(await schema._def.complete('test')).toEqual(['test!']); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('test')).toEqual(['test!']); }); it('works with number schemas', async () => { const schema = completable(z.number(), () => [1, 2, 3]); expect(schema.parse(1)).toBe(1); - expect(await schema._def.complete(0)).toEqual([1, 2, 3]); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!(0)).toEqual([1, 2, 3]); }); it('preserves schema description', () => { diff --git a/src/server/completable.ts b/src/server/completable.ts index 67d91c383..be067ac55 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -1,79 +1,67 @@ -import { ZodTypeAny, ZodTypeDef, ZodType, ParseInput, ParseReturnType, RawCreateParams, ZodErrorMap, ProcessedCreateParams } from 'zod'; +import { AnySchema, SchemaInput } from './zod-compat.js'; -export enum McpZodTypeKind { - Completable = 'McpCompletable' -} +export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); -export type CompleteCallback = ( - value: T['_input'], +export type CompleteCallback = ( + value: SchemaInput, context?: { arguments?: Record; } -) => T['_input'][] | Promise; +) => SchemaInput[] | Promise[]>; -export interface CompletableDef extends ZodTypeDef { - type: T; +export type CompletableMeta = { complete: CompleteCallback; - typeName: McpZodTypeKind.Completable; -} +}; -export class Completable extends ZodType, T['_input']> { - _parse(input: ParseInput): ParseReturnType { - const { ctx } = this._processInputParams(input); - const data = ctx.data; - return this._def.type._parse({ - data, - path: ctx.path, - parent: ctx - }); - } +export type CompletableSchema = T & { + [COMPLETABLE_SYMBOL]: CompletableMeta; +}; - unwrap() { - return this._def.type; - } +/** + * 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); +} - static create = ( - type: T, - params: RawCreateParams & { - complete: CompleteCallback; - } - ): Completable => { - return new Completable({ - type, - typeName: McpZodTypeKind.Completable, - complete: params.complete, - ...processCreateParams(params) - }); - }; +/** + * 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; } /** - * Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP. + * Unwraps a completable schema to get the underlying schema. + * For backward compatibility with code that called `.unwrap()`. */ -export function completable(schema: T, complete: CompleteCallback): Completable { - return Completable.create(schema, { ...schema._def, complete }); +export function unwrapCompletable(schema: CompletableSchema): T { + return schema; } -// Not sure why this isn't exported from Zod: -// https://github.com/colinhacks/zod/blob/f7ad26147ba291cb3fb257545972a8e00e767470/src/types.ts#L130 -function processCreateParams(params: RawCreateParams): ProcessedCreateParams { - if (!params) return {}; - const { errorMap, invalid_type_error, required_error, description } = params; - if (errorMap && (invalid_type_error || required_error)) { - throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`); - } - if (errorMap) return { errorMap: errorMap, description }; - const customMap: ZodErrorMap = (iss, ctx) => { - const { message } = params; +// Legacy exports for backward compatibility +// These types are deprecated but kept for existing code +export enum McpZodTypeKind { + Completable = 'McpCompletable' +} - if (iss.code === 'invalid_enum_value') { - return { message: message ?? ctx.defaultError }; - } - if (typeof ctx.data === 'undefined') { - return { message: message ?? required_error ?? ctx.defaultError }; - } - if (iss.code !== 'invalid_type') return { message: ctx.defaultError }; - return { message: message ?? invalid_type_error ?? ctx.defaultError }; - }; - return { errorMap: customMap, description }; +export interface CompletableDef { + type: T; + complete: CompleteCallback; + typeName: McpZodTypeKind.Completable; } diff --git a/src/server/elicitation.test.ts b/src/server/elicitation.test.ts index dad56d133..ce9e55be2 100644 --- a/src/server/elicitation.test.ts +++ b/src/server/elicitation.test.ts @@ -9,7 +9,7 @@ import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; -import { ElicitRequestParams, ElicitRequestSchema } from '../types.js'; +import { ElicitRequestFormParams, ElicitRequestSchema } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import { CfWorkerJsonSchemaValidator } from '../validation/cfworker-provider.js'; import { Server } from './index.js'; @@ -70,6 +70,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', @@ -93,6 +94,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'What is your age?', requestedSchema: { type: 'object', @@ -116,6 +118,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Do you agree?', requestedSchema: { type: 'object', @@ -149,7 +152,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: userData })); - const result = await server.elicitInput({ + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -166,7 +170,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }, required: ['name', 'email', 'age', 'street', 'city', 'zipCode'] } - }); + }; + const result = await server.elicitInput(formRequestParams); expect(result).toEqual({ action: 'accept', @@ -185,6 +190,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -209,6 +215,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -250,6 +257,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'What is your age?', requestedSchema: { type: 'object', @@ -268,19 +276,20 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo content: { zipCode: 'ABC123' } // Doesn't match pattern })); - await expect( - server.elicitInput({ - message: 'Enter a 5-digit zip code', - requestedSchema: { - type: 'object', - properties: { - // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator - zipCode: { type: 'string', pattern: '^[0-9]{5}$' } - }, - required: ['zipCode'] - } - }) - ).rejects.toThrow(/does not match requested schema/); + const formRequestParams: ElicitRequestFormParams = { + mode: 'form', + message: 'Enter a 5-digit zip code', + requestedSchema: { + type: 'object', + properties: { + // @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator + zipCode: { type: 'string', pattern: '^[0-9]{5}$' } + }, + required: ['zipCode'] + } + }; + + await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/); }); test(`${validatorName}: should allow decline action without validation`, async () => { @@ -289,6 +298,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -310,6 +320,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -340,6 +351,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo }); const nameResult = await server.elicitInput({ + mode: 'form', message: 'What is your name?', requestedSchema: { type: 'object', @@ -385,6 +397,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your name', requestedSchema: { type: 'object', @@ -409,6 +422,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your name', requestedSchema: { type: 'object', @@ -433,6 +447,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo })); const result = await server.elicitInput({ + mode: 'form', message: 'Enter your email', requestedSchema: { type: 'object', @@ -463,13 +478,15 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo { capabilities: { elicitation: { - applyDefaults: true + form: { + applyDefaults: true + } } } } ); - const testSchemaProperties: ElicitRequestParams['requestedSchema'] = { + const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = { type: 'object', properties: { subscribe: { type: 'boolean', default: true }, @@ -542,7 +559,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Client returns no values; SDK should apply defaults automatically (and validate) client.setRequestHandler(ElicitRequestSchema, request => { - expect(request.params.requestedSchema).toEqual(testSchemaProperties); + expect(request.params.mode).toEqual('form'); + expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties); return { action: 'accept', content: {} @@ -553,6 +571,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); const result = await server.elicitInput({ + mode: 'form', message: 'Provide your preferences', requestedSchema: testSchemaProperties }); @@ -582,6 +601,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo await expect( server.elicitInput({ + mode: 'form', message: 'Enter your email', requestedSchema: { type: 'object', @@ -608,6 +628,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -643,6 +664,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -720,6 +742,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -759,6 +782,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -803,6 +827,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -900,6 +925,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -934,6 +960,7 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', diff --git a/src/server/index.test.ts b/src/server/index.test.ts index d2c453931..6b301a4e8 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { z } from 'zod'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import type { Transport } from '../shared/transport.js'; import { CreateMessageRequestSchema, ElicitRequestSchema, + ElicitationCompleteNotificationSchema, ErrorCode, LATEST_PROTOCOL_VERSION, ListPromptsRequestSchema, @@ -19,6 +19,157 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../types.js'; import { Server } from './index.js'; +import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; +import type { AnyObjectSchema } from './zod-compat.js'; +import * as z3 from 'zod/v3'; +import * as z4 from 'zod/v4'; + +describe('Zod v3', () => { + /* + Test that custom request/notification/result schemas can be used with the Server class. + */ + test('should typecheck', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetWeatherRequestSchema = (RequestSchema as unknown as z3.ZodObject).extend({ + method: z3.literal('weather/get'), + params: z3.object({ + city: z3.string() + }) + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetForecastRequestSchema = (RequestSchema as unknown as z3.ZodObject).extend({ + method: z3.literal('weather/forecast'), + params: z3.object({ + city: z3.string(), + days: z3.number() + }) + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherForecastNotificationSchema = (NotificationSchema as unknown as z3.ZodObject).extend({ + method: z3.literal('weather/alert'), + params: z3.object({ + severity: z3.enum(['warning', 'watch']), + message: z3.string() + }) + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z3.ZodObject).or( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GetForecastRequestSchema as unknown as z3.ZodObject + ) as AnyObjectSchema; + const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherResultSchema = (ResultSchema as unknown as z3.ZodObject).extend({ + temperature: z3.number(), + conditions: z3.string() + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type InferSchema = T extends z3.ZodType ? Output : never; + type WeatherRequest = InferSchema; + type WeatherNotification = InferSchema; + type WeatherResult = InferSchema; + + // Create a typed Server for weather data + const weatherServer = new Server( + { + name: 'WeatherServer', + version: '1.0.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + // Typecheck that only valid weather requests/notifications/results are allowed + weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => { + return { + temperature: 72, + conditions: 'sunny' + }; + }); + + weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => { + // Type assertion needed for v3/v4 schema mixing + const params = notification.params as { message: string; severity: 'warning' | 'watch' }; + console.log(`Weather alert: ${params.message}`); + }); + }); +}); + +describe('Zod v4', () => { + 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 Server for weather data + const weatherServer = new Server( + { + name: 'WeatherServer', + version: '1.0.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + // Typecheck that only valid weather requests/notifications/results are allowed + weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => { + return { + temperature: 72, + conditions: 'sunny' + }; + }); + + weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => { + console.log(`Weather alert: ${notification.params.message}`); + }); + }); +}); test('should accept latest protocol version', async () => { let sendPromiseResolve: (value: unknown) => void; @@ -27,9 +178,9 @@ test('should accept latest protocol version', async () => { }); const serverTransport: Transport = { - start: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.id === 1 && message.result) { expect(message.result).toEqual({ protocolVersion: LATEST_PROTOCOL_VERSION, @@ -90,9 +241,9 @@ test('should accept supported older protocol version', async () => { }); const serverTransport: Transport = { - start: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.id === 1 && message.result) { expect(message.result).toEqual({ protocolVersion: OLD_VERSION, @@ -150,9 +301,9 @@ test('should handle unsupported protocol version', async () => { }); const serverTransport: Transport = { - start: jest.fn().mockResolvedValue(undefined), - close: jest.fn().mockResolvedValue(undefined), - send: jest.fn().mockImplementation(message => { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { if (message.id === 1 && message.result) { expect(message.result).toEqual({ protocolVersion: LATEST_PROTOCOL_VERSION, @@ -175,35 +326,704 @@ test('should handle unsupported protocol version', async () => { }, { capabilities: { - prompts: {}, - resources: {}, - tools: {}, - logging: {} + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with unsupported version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 'invalid-version', + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should respect client capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Implement request handler for sampling/createMessage + client.setRequestHandler(CreateMessageRequestSchema, async _request => { + // Mock implementation of createMessage + return { + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'This is a test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(server.getClientCapabilities()).toEqual({ sampling: {} }); + + // This should work because sampling is supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).resolves.not.toThrow(); + + // This should still throw because roots are not supported by the client + await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); +}); + +test('should respect client elicitation capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, params => ({ + action: 'accept', + content: { + username: params.params.message.includes('username') ? 'test-user' : undefined, + confirmed: true + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).rejects.toThrow(/^Client does not support/); +}); + +test('should use elicitInput with mode: "form" by default for backwards compatibility', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, params => ({ + action: 'accept', + content: { + username: params.params.message.includes('username') ? 'test-user' : undefined, + confirmed: true + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // After schema parsing, empty elicitation object should have form capability injected + expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } }); + + // This should work because elicitation is supported by the client + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string', + title: 'Username', + description: 'Your username' + }, + confirmed: { + type: 'boolean', + title: 'Confirm', + description: 'Please confirm', + default: false + } + }, + required: ['username'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + }); + + // This should still throw because sampling is not supported by the client + await expect( + server.createMessage({ + messages: [], + maxTokens: 10 + }) + ).rejects.toThrow(/^Client does not support/); +}); + +test('should throw when elicitInput is called without client form capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} // No form mode capability + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + } + } + }) + ).rejects.toThrow('Client does not support form elicitation.'); +}); + +test('should throw when elicitInput is called without client URL capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} // No URL mode capability + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'cancel' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Open the authorization URL', + elicitationId: 'elicitation-001', + url: 'https://example.com/auth' + }) + ).rejects.toThrow('Client does not support url elicitation.'); +}); + +test('should include form mode when sending elicitation form requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + const receivedModes: string[] = []; + client.setRequestHandler(ElicitRequestSchema, request => { + receivedModes.push(request.params.mode ?? ''); + return { + action: 'accept', + content: { + confirmation: true + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Confirm action', + requestedSchema: { + type: 'object', + properties: { + confirmation: { + type: 'boolean' + } + }, + required: ['confirmation'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + confirmation: true + } + }); + + expect(receivedModes).toEqual(['form']); +}); + +test('should include url mode when sending elicitation URL requests', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedModes: string[] = []; + const receivedIds: string[] = []; + client.setRequestHandler(ElicitRequestSchema, request => { + receivedModes.push(request.params.mode ?? ''); + if (request.params.mode === 'url') { + receivedIds.push(request.params.elicitationId); + } + return { + action: 'decline' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'url', + message: 'Complete verification', + elicitationId: 'elicitation-xyz', + url: 'https://example.com/verify' + }) + ).resolves.toEqual({ + action: 'decline' + }); + + expect(receivedModes).toEqual(['url']); + expect(receivedIds).toEqual(['elicitation-xyz']); +}); + +test('should reject elicitInput when client response violates requested schema', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + + // Bad response: missing required field `username` + content: {} + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + message: 'Please provide your username', + requestedSchema: { + type: 'object', + properties: { + username: { + type: 'string' + } + }, + required: ['username'] + } + }) + ).rejects.toThrow('Elicitation response content does not match requested schema'); +}); + +test('should wrap unexpected validator errors during elicitInput', async () => { + class ThrowingValidator implements jsonSchemaValidator { + getValidator(_schema: JsonSchemaType): JsonSchemaValidator { + throw new Error('boom - validator exploded'); + } + } + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {}, + jsonSchemaValidator: new ThrowingValidator() + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } + } + } + ); + + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + username: 'ignored' + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.elicitInput({ + mode: 'form', + message: 'Provide any data', + requestedSchema: { + type: 'object', + properties: {}, + required: [] + } + }) + ).rejects.toThrow('MCP error -32603: Error validating elicitation response: boom - validator exploded'); +}); + +test('should forward notification options when using elicitation completion notifier', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + client.setNotificationHandler(ElicitationCompleteNotificationSchema, () => {}); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notificationSpy = vi.spyOn(server, 'notification'); + + const notifier = server.createElicitationCompletionNotifier('elicitation-789', { relatedRequestId: 42 }); + await notifier(); + + expect(notificationSpy).toHaveBeenCalledWith( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId: 'elicitation-789' + } + }, + expect.objectContaining({ relatedRequestId: 42 }) + ); +}); + +test('should create notifier that emits elicitation completion notification', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } + } + ); + + const receivedIds: string[] = []; + client.setNotificationHandler(ElicitationCompleteNotificationSchema, notification => { + receivedIds.push(notification.params.elicitationId); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const notifier = server.createElicitationCompletionNotifier('elicitation-123'); + await notifier(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(receivedIds).toEqual(['elicitation-123']); +}); + +test('should throw when creating notifier if client lacks URL elicitation support', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + form: {} + } } } ); - await server.connect(serverTransport); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Simulate initialize request with unsupported version - serverTransport.onmessage?.({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: 'invalid-version', - capabilities: {}, - clientInfo: { - name: 'test client', - version: '1.0' - } - } - }); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - await expect(sendPromise).resolves.toBeUndefined(); + expect(() => server.createElicitationCompletionNotifier('elicitation-123')).toThrow( + 'Client does not support URL elicitation (required for notifications/elicitation/complete)' + ); }); -test('should respect client capabilities', async () => { +test('should apply back-compat form capability injection when client sends empty elicitation object', async () => { const server = new Server( { name: 'test server', @@ -215,8 +1035,7 @@ test('should respect client capabilities', async () => { resources: {}, tools: {}, logging: {} - }, - enforceStrictCapabilities: true + } } ); @@ -227,43 +1046,25 @@ test('should respect client capabilities', async () => { }, { capabilities: { - sampling: {} + elicitation: {} } } ); - // Implement request handler for sampling/createMessage - client.setRequestHandler(CreateMessageRequestSchema, async _request => { - // Mock implementation of createMessage - return { - model: 'test-model', - role: 'assistant', - content: { - type: 'text', - text: 'This is a test response' - } - }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - expect(server.getClientCapabilities()).toEqual({ sampling: {} }); - - // This should work because sampling is supported by the client - await expect( - server.createMessage({ - messages: [], - maxTokens: 10 - }) - ).resolves.not.toThrow(); - - // This should still throw because roots are not supported by the client - await expect(server.listRoots()).rejects.toThrow(/^Client does not support/); + // Verify that the schema preprocessing injected form capability + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({}); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); }); -test('should respect client elicitation capabilities', async () => { +test('should preserve form capability configuration when client enables applyDefaults', async () => { const server = new Server( { name: 'test server', @@ -275,8 +1076,7 @@ test('should respect client elicitation capabilities', async () => { resources: {}, tools: {}, logging: {} - }, - enforceStrictCapabilities: true + } } ); @@ -287,62 +1087,26 @@ test('should respect client elicitation capabilities', async () => { }, { capabilities: { - elicitation: {} + elicitation: { + form: { + applyDefaults: true + } + } } } ); - client.setRequestHandler(ElicitRequestSchema, params => ({ - action: 'accept', - content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, - confirmed: true - } - })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - expect(server.getClientCapabilities()).toEqual({ elicitation: {} }); - - // This should work because elicitation is supported by the client - await expect( - server.elicitInput({ - message: 'Please provide your username', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string', - title: 'Username', - description: 'Your username' - }, - confirmed: { - type: 'boolean', - title: 'Confirm', - description: 'Please confirm', - default: false - } - }, - required: ['username'] - } - }) - ).resolves.toEqual({ - action: 'accept', - content: { - username: 'test-user', - confirmed: true - } - }); - - // This should still throw because sampling is not supported by the client - await expect( - server.createMessage({ - messages: [], - maxTokens: 10 - }) - ).rejects.toThrow(/^Client does not support/); + // Verify that the schema preprocessing preserved the form capability configuration + const clientCapabilities = server.getClientCapabilities(); + expect(clientCapabilities).toBeDefined(); + expect(clientCapabilities?.elicitation).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toBeDefined(); + expect(clientCapabilities?.elicitation?.form).toEqual({ applyDefaults: true }); + expect(clientCapabilities?.elicitation?.url).toBeUndefined(); }); test('should validate elicitation response against requested schema', async () => { @@ -391,6 +1155,7 @@ test('should validate elicitation response against requested schema', async () = // Test with valid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -467,6 +1232,7 @@ test('should reject elicitation response with invalid data', async () => { // Test with invalid response await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your information', requestedSchema: { type: 'object', @@ -545,6 +1311,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test reject - should not validate await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your name', requestedSchema: schema }) @@ -555,6 +1322,7 @@ test('should allow elicitation reject and cancel without validation', async () = // Test cancel - should not validate await expect( server.elicitInput({ + mode: 'form', message: 'Please provide your name', requestedSchema: schema }) @@ -628,73 +1396,6 @@ test('should only allow setRequestHandler for declared capabilities', () => { }).toThrow(/^Server does not support logging/); }); -/* - Test that custom request/notification/result schemas can be used with the Server class. - */ -test('should typecheck', () => { - const GetWeatherRequestSchema = RequestSchema.extend({ - method: z.literal('weather/get'), - params: z.object({ - city: z.string() - }) - }); - - const GetForecastRequestSchema = RequestSchema.extend({ - method: z.literal('weather/forecast'), - params: z.object({ - city: z.string(), - days: z.number() - }) - }); - - const WeatherForecastNotificationSchema = NotificationSchema.extend({ - method: z.literal('weather/alert'), - params: z.object({ - severity: z.enum(['warning', 'watch']), - message: z.string() - }) - }); - - const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); - const WeatherNotificationSchema = WeatherForecastNotificationSchema; - const WeatherResultSchema = ResultSchema.extend({ - temperature: z.number(), - conditions: z.string() - }); - - type WeatherRequest = z.infer; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; - - // Create a typed Server for weather data - const weatherServer = new Server( - { - name: 'WeatherServer', - version: '1.0.0' - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {}, - logging: {} - } - } - ); - - // Typecheck that only valid weather requests/notifications/results are allowed - weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => { - return { - temperature: 72, - conditions: 'sunny' - }; - }); - - weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => { - console.log(`Weather alert: ${notification.params.message}`); - }); -}); - test('should handle server cancelling a request', async () => { const server = new Server( { @@ -864,7 +1565,7 @@ test('should respect log level for transport without sessionId', async () => { }; // Test the one that makes it through - clientTransport.onmessage = jest.fn().mockImplementation(message => { + clientTransport.onmessage = vi.fn().mockImplementation(message => { expect(message).toEqual({ jsonrpc: '2.0', method: 'notifications/message', @@ -881,6 +1582,341 @@ test('should respect log level for transport without sessionId', async () => { expect(clientTransport.onmessage).toHaveBeenCalled(); }); +describe('createMessage validation', () => { + test('should throw when tools are provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} } } // No tools capability + ); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('Client does not support sampling tools capability.'); + }); + + test('should throw when toolChoice is provided without sampling.tools capability', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client( + { name: 'test client', version: '1.0' }, + { capabilities: { sampling: {} } } // No tools capability + ); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + toolChoice: { mode: 'auto' } + }) + ).rejects.toThrow('Client does not support sampling tools capability.'); + }); + + test('should throw when tool_result is mixed with other content', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { + role: 'user', + content: [ + { type: 'tool_result', toolUseId: 'call_1', content: [] }, + { type: 'text', text: 'mixed content' } // Mixed! + ] + } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('The last message must contain only tool_result content if any is present'); + }); + + test('should throw when tool_result has no matching tool_use in previous message', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // tool_result without previous tool_use + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('tool_result blocks are not matching any tool_use from the previous message'); + }); + + test('should throw when tool_result IDs do not match tool_use IDs', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'wrong_id', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should allow text-only messages with tools (no tool_results)', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should allow valid matching tool_result/tool_use IDs', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should throw when user sends text instead of tool_result after tool_use', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // User ignores tool_use and sends text instead + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'text', text: 'actually nevermind' } } + ], + maxTokens: 100, + tools: [{ name: 'test_tool', inputSchema: { type: 'object' } }] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should throw when only some tool_results are provided for parallel tool_use', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Parallel tool_use but only one tool_result provided + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'call_1', name: 'tool_a', input: {} }, + { type: 'tool_use', id: 'call_2', name: 'tool_b', input: {} } + ] + }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100, + tools: [ + { name: 'tool_a', inputSchema: { type: 'object' } }, + { name: 'tool_b', inputSchema: { type: 'object' } } + ] + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should validate tool_use/tool_result even without tools in current request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Previous request returned tool_use, now sending tool_result without tools param + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'wrong_id', content: [] } } + ], + maxTokens: 100 + // Note: no tools param - this is a follow-up request after tool execution + }) + ).rejects.toThrow('ids of tool_result blocks and tool_use blocks from previous message do not match'); + }); + + test('should allow valid tool_use/tool_result without tools in current request', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Previous request returned tool_use, now sending matching tool_result without tools param + await expect( + server.createMessage({ + messages: [ + { role: 'user', content: { type: 'text', text: 'hello' } }, + { role: 'assistant', content: { type: 'tool_use', id: 'call_1', name: 'test_tool', input: {} } }, + { role: 'user', content: { type: 'tool_result', toolUseId: 'call_1', content: [] } } + ], + maxTokens: 100 + // Note: no tools param - this is a follow-up request after tool execution + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); + + test('should handle empty messages array', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Response' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Empty messages array should not crash + await expect( + server.createMessage({ + messages: [], + maxTokens: 100 + }) + ).resolves.toMatchObject({ model: 'test-model' }); + }); +}); + test('should respect log level for transport with sessionId', async () => { const server = new Server( { @@ -933,7 +1969,7 @@ test('should respect log level for transport with sessionId', async () => { }; // Test the one that makes it through - clientTransport.onmessage = jest.fn().mockImplementation(message => { + clientTransport.onmessage = vi.fn().mockImplementation(message => { expect(message).toEqual({ jsonrpc: '2.0', method: 'notifications/message', diff --git a/src/server/index.ts b/src/server/index.ts index 47b5f538f..975efb8e2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,9 +1,10 @@ -import { mergeCapabilities, Protocol, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; import { type ClientCapabilities, type CreateMessageRequest, CreateMessageResultSchema, - type ElicitRequest, + type ElicitRequestFormParams, + type ElicitRequestURLParams, type ElicitResult, ElicitResultSchema, EmptyResultSchema, @@ -29,7 +30,9 @@ import { type ServerRequest, type ServerResult, SetLevelRequestSchema, - SUPPORTED_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS, + type ToolResultContent, + type ToolUseContent } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, jsonSchemaValidator } from '../validation/types.js'; @@ -225,6 +228,12 @@ export class Server< } 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; @@ -317,36 +326,129 @@ export class Server< } async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { + // 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'); + } + } + } + return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } - async elicitInput(params: ElicitRequest['params'], options?: RequestOptions): Promise { - const result = await this.request({ method: 'elicitation/create', params }, ElicitResultSchema, 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'; - // Validate the response content against the requested schema if action is "accept" - if (result.action === 'accept' && result.content && params.requestedSchema) { - try { - const validator = this._jsonSchemaValidator.getValidator(params.requestedSchema as JsonSchemaType); - const validationResult = validator(result.content); + switch (mode) { + case 'url': { + if (!this._clientCapabilities?.elicitation?.url) { + throw new Error('Client does not support url elicitation.'); + } - if (!validationResult.valid) { - throw new McpError( - ErrorCode.InvalidParams, - `Elicitation response content does not match requested schema: ${validationResult.errorMessage}` - ); + 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.'); } - } catch (error) { - if (error instanceof McpError) { - throw error; + + 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)}` + ); + } } - 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 result; + return () => + this.notification( + { + method: 'notifications/elicitation/complete', + params: { + elicitationId + } + }, + options + ); } async listRoots(params?: ListRootsRequest['params'], options?: RequestOptions) { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 9bc40f316..776d0a129 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1,4 +1,3 @@ -import { z } from 'zod'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import { getDisplayName } from '../shared/metadataUtils.js'; @@ -15,4505 +14,4572 @@ import { LoggingMessageNotificationSchema, type Notification, ReadResourceResultSchema, - type TextContent + type TextContent, + UrlElicitationRequiredError, + ErrorCode } from '../types.js'; import { completable } from './completable.js'; import { McpServer, ResourceTemplate } from './mcp.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; -describe('McpServer', () => { - /*** - * Test: Basic Server Instance - */ - test('should expose underlying Server instance', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - expect(mcpServer.server).toBeDefined(); - }); +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; - /*** - * Test: Notification Sending via Server - */ - test('should allow sending notifications via Server', async () => { - const mcpServer = new McpServer( - { + describe('McpServer', () => { + /*** + * Test: Basic Server Instance + */ + test('should expose underlying Server instance', () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' - }, - { capabilities: { logging: {} } } - ); + }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(mcpServer.server).toBeDefined(); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // This should work because we're using the underlying server - await expect( - mcpServer.server.sendLoggingMessage({ - level: 'info', - data: 'Test log message' - }) - ).resolves.not.toThrow(); - - expect(notifications).toMatchObject([ - { - method: 'notifications/message', - params: { - level: 'info', - data: 'Test log message' - } - } - ]); - }); - /*** - * Test: Progress Notification with Message Field - */ - test('should send progress notifications with message field', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + /*** + * Test: Notification Sending via Server + */ + test('should allow sending notifications via Server', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); - // Create a tool that sends progress updates - mcpServer.tool( - 'long-operation', - 'A long running operation with progress updates', - { - steps: z.number().min(1).describe('Number of steps to perform') - }, - async ({ steps }, { sendNotification, _meta }) => { - const progressToken = _meta?.progressToken; - - if (progressToken) { - // Send progress notification for each step - for (let i = 1; i <= steps; i++) { - await sendNotification({ - method: 'notifications/progress', - params: { - progressToken, - progress: i, - total: steps, - message: `Completed step ${i} of ${steps}` - } - }); - } - } + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - return { - content: [ - { - type: 'text' as const, - text: `Operation completed with ${steps} steps` - } - ] - }; - } - ); - - const progressUpdates: Array<{ - progress: number; - total?: number; - message?: string; - }> = []; - - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // This should work because we're using the underlying server + await expect( + mcpServer.server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); - // Call the tool with progress tracking - await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: { steps: 3 }, - _meta: { - progressToken: 'progress-test-1' + expect(notifications).toMatchObject([ + { + method: 'notifications/message', + params: { + level: 'info', + data: 'Test log message' } } - }, - CallToolResultSchema, - { - onprogress: progress => { - progressUpdates.push(progress); - } - } - ); - - // Verify progress notifications were received with message field - expect(progressUpdates).toHaveLength(3); - expect(progressUpdates[0]).toMatchObject({ - progress: 1, - total: 3, - message: 'Completed step 1 of 3' + ]); }); - expect(progressUpdates[1]).toMatchObject({ - progress: 2, - total: 3, - message: 'Completed step 2 of 3' - }); - expect(progressUpdates[2]).toMatchObject({ - progress: 3, - total: 3, - message: 'Completed step 3 of 3' - }); - }); -}); - -describe('ResourceTemplate', () => { - /*** - * Test: ResourceTemplate Creation with String Pattern - */ - test('should create ResourceTemplate with string pattern', () => { - const template = new ResourceTemplate('test://{category}/{id}', { - list: undefined - }); - expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate Creation with UriTemplate Instance - */ - test('should create ResourceTemplate with UriTemplate', () => { - const uriTemplate = new UriTemplate('test://{category}/{id}'); - const template = new ResourceTemplate(uriTemplate, { list: undefined }); - expect(template.uriTemplate).toBe(uriTemplate); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate with List Callback - */ - test('should create ResourceTemplate with list callback', async () => { - const list = jest.fn().mockResolvedValue({ - resources: [{ name: 'Test', uri: 'test://example' }] - }); - - const template = new ResourceTemplate('test://{id}', { list }); - expect(template.listCallback).toBe(list); - - const abortController = new AbortController(); - const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); - } - }); - expect(result?.resources).toHaveLength(1); - expect(list).toHaveBeenCalled(); - }); -}); - -describe('tool()', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - /*** - * Test: Zero-Argument Tool Registration - */ - test('should register zero-argument tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + /*** + * Test: Progress Notification with Message Field + */ + test('should send progress notifications with message field', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.tool('test', async () => ({ - content: [ + // Create a tool that sends progress updates + mcpServer.tool( + 'long-operation', + 'A long running operation with progress updates', { - type: 'text', - text: 'Test response' - } - ] - })); + steps: z.number().min(1).describe('Number of steps to perform') + }, + async ({ steps }, { sendNotification, _meta }) => { + const progressToken = _meta?.progressToken; + + if (progressToken) { + // Send progress notification for each step + for (let i = 1; i <= steps; i++) { + await sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: i, + total: steps, + message: `Completed step ${i} of ${steps}` + } + }); + } + } - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + return { + content: [ + { + type: 'text' as const, + text: `Operation completed with ${steps} steps` + } + ] + }; + } + ); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + const progressUpdates: Array<{ + progress: number; + total?: number; + message?: string; + }> = []; - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toEqual({ - type: 'object', - properties: {} - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Adding the tool before the connection was established means no notification was sent - expect(notifications).toHaveLength(0); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Adding another tool triggers the update notification - mcpServer.tool('test2', async () => ({ - content: [ + // Call the tool with progress tracking + await client.request( { - type: 'text', - text: 'Test response' + method: 'tools/call', + params: { + name: 'long-operation', + arguments: { steps: 3 }, + _meta: { + progressToken: 'progress-test-1' + } + } + }, + CallToolResultSchema, + { + onprogress: progress => { + progressUpdates.push(progress); + } } - ] - })); + ); + + // Verify progress notifications were received with message field + expect(progressUpdates).toHaveLength(3); + expect(progressUpdates[0]).toMatchObject({ + progress: 1, + total: 3, + message: 'Completed step 1 of 3' + }); + expect(progressUpdates[1]).toMatchObject({ + progress: 2, + total: 3, + message: 'Completed step 2 of 3' + }); + expect(progressUpdates[2]).toMatchObject({ + progress: 3, + total: 3, + message: 'Completed step 3 of 3' + }); + }); + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + describe('ResourceTemplate', () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ + test('should create ResourceTemplate with string pattern', () => { + const template = new ResourceTemplate('test://{category}/{id}', { + list: undefined + }); + expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ + test('should create ResourceTemplate with UriTemplate', () => { + const uriTemplate = new UriTemplate('test://{category}/{id}'); + const template = new ResourceTemplate(uriTemplate, { list: undefined }); + expect(template.uriTemplate).toBe(uriTemplate); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate with List Callback + */ + test('should create ResourceTemplate with list callback', async () => { + const list = vi.fn().mockResolvedValue({ + resources: [{ name: 'Test', uri: 'test://example' }] + }); - expect(notifications).toMatchObject([ - { - method: 'notifications/tools/list_changed' - } - ]); - }); + const template = new ResourceTemplate('test://{id}', { list }); + expect(template.listCallback).toBe(list); - /*** - * Test: Updating Existing Tool - */ - test('should update existing tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + const abortController = new AbortController(); + const result = await template.listCallback?.({ + signal: abortController.signal, + requestId: 'not-implemented', + sendRequest: () => { + throw new Error('Not implemented'); + }, + sendNotification: () => { + throw new Error('Not implemented'); + } + }); + expect(result?.resources).toHaveLength(1); + expect(list).toHaveBeenCalled(); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + }); + + describe('tool()', () => { + afterEach(() => { + vi.restoreAllMocks(); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Initial response' - } - ] - })); + /*** + * Test: Zero-Argument Tool Registration + */ + test('should register zero-argument tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Update the tool - tool.update({ - callback: async () => ({ + mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: 'Updated response' + text: 'Test response' } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the tool and verify we get the updated response - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test' - } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Updated response' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toEqual({ + type: 'object', + properties: {} + }); - /*** - * Test: Updating Tool with Schema - */ - test('should update tool with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial tool - const tool = mcpServer.tool( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ + // Adding the tool before the connection was established means no notification was sent + expect(notifications).toHaveLength(0); + + // Adding another tool triggers the update notification + mcpServer.tool('test2', async () => ({ content: [ { type: 'text', - text: `Initial: ${name}` + text: 'Test response' } ] - }) - ); - - // Update the tool with a different schema - tool.update({ - paramsSchema: { - name: z.string(), - value: z.number() - }, - callback: async ({ name, value }) => ({ + })); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { + method: 'notifications/tools/list_changed' + } + ]); + }); + + /*** + * Test: Updating Existing Tool + */ + test('should update existing tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: `Updated: ${name}, ${value}` + text: 'Initial response' } ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); - // Verify the schema was updated - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(listResult.tools[0].inputSchema).toMatchObject({ - properties: { - name: { type: 'string' }, - value: { type: 'number' } - } - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the tool with the new schema - const callResult = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - name: 'test', - value: 42 + // Call the tool and verify we get the updated response + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test' } - } - }, - CallToolResultSchema - ); - - expect(callResult.content).toEqual([ - { - type: 'text', - text: 'Updated: test, 42' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Tool List Changed Notifications - */ - test('should send tool list changed notifications when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + }, + CallToolResultSchema + ); - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ + expect(result.content).toEqual([ { type: 'text', - text: 'Test response' + text: 'Updated response' } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + ]); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - expect(notifications).toHaveLength(0); - - // Now update the tool - tool.update({ - callback: async () => ({ - content: [ - { - type: 'text', - text: 'Updated response' - } - ] - }) + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + /*** + * Test: Updating Tool with Schema + */ + test('should update tool with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); + // Register initial tool + const tool = mcpServer.tool( + 'test', + { + name: z.string() + }, + async ({ name }) => ({ + content: [ + { + type: 'text', + text: `Initial: ${name}` + } + ] + }) + ); - // Now delete the tool - tool.remove(); + // Update the tool with a different schema + tool.update({ + paramsSchema: { + name: z.string(), + value: z.number() + }, + callback: async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `Updated: ${name}, ${value}` + } + ] + }) + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(notifications).toMatchObject([ - { method: 'notifications/tools/list_changed' }, - { method: 'notifications/tools/list_changed' } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - /*** - * Test: Tool Registration with Parameters - */ - test('should register tool with params', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Verify the schema was updated + const listResult = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); - // old api - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string(), value: z.number() } - }, - async ({ name, value }) => ({ - content: [{ type: 'text', text: `${name}: ${value}` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - 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(listResult.tools[0].inputSchema).toMatchObject({ + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); - /*** - * Test: Tool Registration with Description - */ - test('should register tool with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Call the tool with the new schema + const callResult = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 42 + } + } + }, + CallToolResultSchema + ); - // old api - mcpServer.tool('test', 'Test description', async () => ({ - content: [ + expect(callResult.content).toEqual([ { type: 'text', - text: 'Test response' + text: 'Updated: test, 42' } - ] - })); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - description: 'Test description' - }, - async () => ({ - content: [ - { - type: 'text' as const, - text: 'Test response' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + ]); - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('Test description'); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('Test description'); - }); - - /*** - * Test: Tool Registration with Annotations - */ - test('should register tool with annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - mcpServer.registerTool( - 'test (new api)', - { - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async () => ({ + /*** + * Test: Tool List Changed Notifications + */ + test('should send tool list changed notifications when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ content: [ { - type: 'text' as const, + type: 'text', text: 'Test response' } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - expect(result.tools).toHaveLength(2); - 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({ - title: 'Test Tool', - readOnlyHint: true - }); - }); + expect(notifications).toHaveLength(0); - /*** - * Test: Tool Registration with Parameters and Annotations - */ - test('should register tool with params and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Now update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); - mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - })); - - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string() }, - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Now delete the tool + tool.remove(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { name: { type: 'string' } } - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true + expect(notifications).toMatchObject([ + { method: 'notifications/tools/list_changed' }, + { method: 'notifications/tools/list_changed' } + ]); }); - 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); - }); - /*** - * Test: Tool Registration with Description, Parameters, and Annotations - */ - test('should register tool with description, params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Tool Registration with Parameters + */ + test('should register tool with params', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.tool( - 'test', - 'A tool with everything', - { name: z.string() }, - { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything', - inputSchema: { name: z.string() }, - annotations: { - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + // old api + mcpServer.tool( + 'test', + { + name: z.string(), + value: z.number() + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // new api + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { name: z.string(), value: z.number() } + }, + async ({ name, value }) => ({ + content: [{ type: 'text', text: `${name}: ${value}` }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - 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({ - type: 'object', - properties: { name: { type: 'string' } } + 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({ + 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[0].annotations).toEqual({ - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false + + /*** + * Test: Tool Registration with Description + */ + test('should register tool with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // old api + mcpServer.tool('test', 'Test description', async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + // new api + mcpServer.registerTool( + 'test (new api)', + { + description: 'Test description' + }, + async () => ({ + content: [ + { + type: 'text' as const, + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + 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[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); - }); - /*** - * Test: Tool Registration with Description, Empty Parameters, and Annotations - */ - test('should register tool with description, empty params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + /*** + * Test: Tool Registration with Annotations + */ + test('should register tool with annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + mcpServer.registerTool( + 'test (new api)', + { + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async () => ({ + content: [ + { + type: 'text' as const, + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(2); + 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({ + title: 'Test Tool', + readOnlyHint: true + }); }); - const client = new Client({ - name: 'test client', - version: '1.0' + + /*** + * Test: Tool Registration with Parameters and Annotations + */ + test('should register tool with params and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { name: z.string() }, + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0].annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); + 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); }); - mcpServer.tool( - 'test', - 'A tool with everything but empty params', - {}, - { - title: 'Complete Test Tool with empty params', + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ + test('should register tool with description, params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'A tool with everything', + { name: z.string() }, + { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + mcpServer.registerTool( + 'test (new api)', + { + description: 'A tool with everything', + inputSchema: { name: z.string() }, + annotations: { + title: 'Complete Test Tool', + readOnlyHint: true, + openWorldHint: false + } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, 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({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0].annotations).toEqual({ + title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false - }, - async () => ({ - content: [{ type: 'text', text: 'Test response' }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything but empty params', - inputSchema: {}, - 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); + }); + + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ + test('should register tool with description, empty params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'A tool with everything but empty params', + {}, + { title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Test response' }] - }) - ); + }, + async () => ({ + content: [{ type: 'text', text: 'Test response' }] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.registerTool( + 'test (new api)', + { + description: 'A tool with everything but empty params', + inputSchema: {}, + annotations: { + title: 'Complete Test Tool with empty params', + readOnlyHint: true, + openWorldHint: false + } + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Test response' }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - 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({ - type: 'object', - properties: {} - }); - 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); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: Tool Argument Validation - */ - test('should validate tool args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + 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({ + type: 'object', + properties: {} + }); + 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); }); - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ + /*** + * Test: Tool Argument Validation + */ + test('should validate tool args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + { + name: z.string(), + value: z.number() + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); + + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { + name: z.string(), + value: z.number() + } + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'not a number' + } + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: `${name}: ${value}` + text: expect.stringContaining('Input validation error: Invalid arguments for tool test') } - ] - }) - ); + ]) + ); - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { - name: z.string(), - value: z.number() - } - }, - async ({ name, value }) => ({ + const result2 = await client.request( + { + method: 'tools/call', + params: { + name: 'test (new api)', + arguments: { + name: 'test', + value: 'not a number' + } + } + }, + CallToolResultSchema + ); + + expect(result2.isError).toBe(true); + expect(result2.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)') + } + ]) + ); + }); + + /*** + * Test: Preventing Duplicate Tool Registration + */ + test('should prevent duplicate tool registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: `${name}: ${value}` + text: 'Test response' } ] - }) - ); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(() => { + mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Test response 2' + } + ] + })); + }).toThrow(/already registered/); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + /*** + * Test: Multiple Tool Registration + */ + test('should allow registering multiple tools', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'not a number' + // This should succeed + mcpServer.tool('tool1', () => ({ content: [] })); + + // This should also succeed and not throw about request handlers + mcpServer.tool('tool2', () => ({ content: [] })); + }); + + /*** + * Test: Tool with Output Schema and Structured Content + */ + test('should support tool with outputSchema and structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema + mcpServer.registerTool( + 'test', + { + description: 'Test tool with structured output', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() } - } - }, - CallToolResultSchema - ); + }, + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }, + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }) + } + ] + }) + ); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request( { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test') - } - ]) - ); - - const result2 = await client.request( - { - method: 'tools/call', - params: { - name: 'test (new api)', - arguments: { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].outputSchema).toMatchObject({ + type: 'object', + properties: { + processedInput: { type: 'string' }, + resultType: { type: 'string' }, + timestamp: { type: 'string' } + }, + required: ['processedInput', 'resultType', 'timestamp'] + }); + + // Call the tool and verify it returns valid structuredContent + const result = await client.request( + { + method: 'tools/call', + params: { name: 'test', - value: 'not a number' + arguments: { + input: 'hello' + } } - } - }, - CallToolResultSchema - ); + }, + CallToolResultSchema + ); + + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe('hello'); + expect(structuredContent.resultType).toBe('structured'); + expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: 'text' }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); + }); + + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(result2.isError).toBe(true); - expect(result2.content).toEqual( - expect.arrayContaining([ + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + 'test', { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)') + description: 'Test tool with output schema but missing structured content', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string() + } + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw an error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' } - ]) - ); - }); + }); - /*** - * Test: Preventing Duplicate Tool Registration - */ - test('should prevent duplicate tool registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining( + 'Output validation error: Tool test has an output schema but no structured content was provided' + ) + } + ]) + ); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should skip outputSchema validation when isError is true', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.tool('test', async () => ({ - content: [ + mcpServer.registerTool( + 'test', { - type: 'text', - text: 'Test response' - } - ] - })); + description: 'Test tool with output schema but missing structured content', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string() + } + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ], + isError: true + }) + ); - expect(() => { - mcpServer.tool('test', async () => ({ + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }) + ).resolves.toStrictEqual({ content: [ { type: 'text', - text: 'Test response 2' + text: `Processed: hello` } - ] - })); - }).toThrow(/already registered/); - }); + ], + isError: true + }); + }); - /*** - * Test: Multiple Tool Registration - */ - test('should allow registering multiple tools', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test('should fail schema validation when tool returns invalid structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns invalid data + mcpServer.registerTool( + 'test', + { + description: 'Test tool with invalid structured output', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + } + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + }) + } + ], + structuredContent: { + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + } + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw a server-side validation error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Output validation error: Invalid structured content for tool test') + } + ]) + ); }); - // This should succeed - mcpServer.tool('tool1', () => ({ content: [] })); + /*** + * Test: Pass Session ID to Tool Callback + */ + test('should pass sessionId to tool callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // This should also succeed and not throw about request handlers - mcpServer.tool('tool2', () => ({ content: [] })); - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedSessionId: string | undefined; + mcpServer.tool('test-tool', async extra => { + receivedSessionId = extra.sessionId; + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Set a test sessionId on the server transport + serverTransport.sessionId = 'test-session-123'; + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); - /*** - * Test: Tool with Output Schema and Structured Content - */ - test('should support tool with outputSchema and structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(receivedSessionId).toBe('test-session-123'); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Pass Request ID to Tool Callback + */ + test('should pass requestId to tool callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Register a tool with outputSchema - mcpServer.registerTool( - 'test', - { - description: 'Test tool with structured output', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - structuredContent: { - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' - }, - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' - }) - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Verify the tool registration includes outputSchema - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toMatchObject({ - type: 'object', - properties: { - processedInput: { type: 'string' }, - resultType: { type: 'string' }, - timestamp: { type: 'string' } - }, - required: ['processedInput', 'resultType', 'timestamp'] - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Call the tool and verify it returns valid structuredContent - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - input: 'hello' - } - } - }, - CallToolResultSchema - ); - - expect(result.structuredContent).toBeDefined(); - const structuredContent = result.structuredContent as { - processedInput: string; - resultType: string; - timestamp: string; - }; - expect(structuredContent.processedInput).toBe('hello'); - expect(structuredContent.resultType).toBe('structured'); - expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); - - // For backward compatibility, content is auto-generated from structuredContent - expect(result.content).toBeDefined(); - expect(result.content!).toHaveLength(1); - expect(result.content![0]).toMatchObject({ type: 'text' }); - const textContent = result.content![0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); - }); + let receivedRequestId: string | number | undefined; + mcpServer.tool('request-id-test', async extra => { + receivedRequestId = extra.requestId; + return { + content: [ + { + type: 'text', + text: `Received request ID: ${extra.requestId}` + } + ] + }; + }); - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should throw error when tool with outputSchema returns no structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { - input: z.string() + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'request-id-test' + } }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() - } - }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ + CallToolResultSchema + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: `Processed: ${input}` + text: expect.stringContaining('Received request ID:') } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool and expect it to throw an error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } + ]) + ); }); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + /*** + * Test: Send Notification within Tool Call + */ + test('should provide sendNotification within tool call', async () => { + const mcpServer = new McpServer( { - type: 'text', - text: expect.stringContaining( - 'Output validation error: Tool test has an output schema but no structured content was provided' - ) - } - ]) - ); - }); - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should skip outputSchema validation when isError is true', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ], - isError: true - }) - ); + let receivedLogMessage: string | undefined; + const loggingMessage = 'hello here is log message 1'; - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + receivedLogMessage = notification.params.data as string; + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + mcpServer.tool('test-tool', async ({ sendNotification }) => { + await sendNotification({ + method: 'notifications/message', + params: { level: 'debug', data: loggingMessage } + }); + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); - await expect( - client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }) - ).resolves.toStrictEqual({ - content: [ + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await client.request( { - type: 'text', - text: `Processed: hello` - } - ], - isError: true + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); + expect(receivedLogMessage).toBe(loggingMessage); }); - }); - /*** - * Test: Schema Validation Failure for Invalid Structured Content - */ - test('should fail schema validation when tool returns invalid structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + /*** + * Test: Client to Server Tool Call + */ + test('should allow client to call server tools', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Register a tool with outputSchema that returns invalid data - mcpServer.registerTool( - 'test', - { - description: 'Test tool with invalid structured output', - inputSchema: { + mcpServer.tool( + 'test', + 'Test tool', + { input: z.string() }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - }) - } - ], - structuredContent: { - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - } - }) - ); + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Call the tool and expect it to throw a server-side validation error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }); + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }, + CallToolResultSchema + ); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + expect(result.content).toEqual([ { type: 'text', - text: expect.stringContaining('Output validation error: Invalid structured content for tool test') + text: 'Processed: hello' } - ]) - ); - }); - - /*** - * Test: Pass Session ID to Tool Callback - */ - test('should pass sessionId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedSessionId: string | undefined; - mcpServer.tool('test-tool', async extra => { - receivedSessionId = extra.sessionId; - return { - content: [ - { - type: 'text', - text: 'Test response' - } - ] - }; + ]); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set a test sessionId on the server transport - serverTransport.sessionId = 'test-session-123'; - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + /*** + * Test: Graceful Tool Error Handling + */ + test('should handle server tool errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(receivedSessionId).toBe('test-session-123'); - }); + mcpServer.tool('error-test', async () => { + throw new Error('Tool execution failed'); + }); - /*** - * Test: Pass Request ID to Tool Callback - */ - test('should pass requestId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - let receivedRequestId: string | number | undefined; - mcpServer.tool('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - content: [ - { - type: 'text', - text: `Received request ID: ${extra.requestId}` + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'error-test' } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + }, + CallToolResultSchema + ); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'request-id-test' - } - }, - CallToolResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + expect(result.isError).toBe(true); + expect(result.content).toEqual([ { type: 'text', - text: expect.stringContaining('Received request ID:') + text: 'Tool execution failed' } - ]) - ); - }); + ]); + }); - /*** - * Test: Send Notification within Tool Call - */ - test('should provide sendNotification within tool call', async () => { - const mcpServer = new McpServer( - { + /*** + * Test: McpError for Invalid Tool Name + */ + test('should throw McpError for invalid tool name', async () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' - }, - { capabilities: { logging: {} } } - ); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedLogMessage: string | undefined; - const loggingMessage = 'hello here is log message 1'; - - client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - receivedLogMessage = notification.params.data as string; - }); + }); - mcpServer.tool('test-tool', async ({ sendNotification }) => { - await sendNotification({ - method: 'notifications/message', - params: { level: 'debug', data: loggingMessage } + const client = new Client({ + name: 'test client', + version: '1.0' }); - return { + + mcpServer.tool('test-tool', async () => ({ content: [ { type: 'text', text: 'Test response' } ] - }; - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); - expect(receivedLogMessage).toBe(loggingMessage); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Client to Server Tool Call - */ - test('should allow client to call server tools', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'nonexistent-tool' + } + }, + CallToolResultSchema + ); - mcpServer.tool( - 'test', - 'Test tool', - { - input: z.string() - }, - async ({ input }) => ({ - content: [ + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: `Processed: ${input}` + text: expect.stringContaining('Tool nonexistent-tool not found') } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + ]) + ); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + /*** + * Test: URL Elicitation Required Error Propagation + */ + test('should propagate UrlElicitationRequiredError to client callers', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - input: 'hello' + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } } } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: hello' - } - ]); - }); - - /*** - * Test: Graceful Tool Error Handling - */ - test('should handle server tool errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); + ); - mcpServer.tool('error-test', async () => { - throw new Error('Tool execution failed'); - }); + const elicitationParams = { + mode: 'url' as const, + elicitationId: 'elicitation-123', + url: 'https://mcp.example.com/connect', + message: 'Authorization required' + }; - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.tool('needs-authorization', async () => { + throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required'); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'error-test' - } - }, - CallToolResultSchema - ); - - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Tool execution failed' - } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: McpError for Invalid Tool Name - */ - test('should throw McpError for invalid tool name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + await client + .callTool({ + name: 'needs-authorization' + }) + .then(() => { + throw new Error('Expected callTool to throw UrlElicitationRequiredError'); + }) + .catch(error => { + expect(error).toBeInstanceOf(UrlElicitationRequiredError); + if (error instanceof UrlElicitationRequiredError) { + expect(error.code).toBe(ErrorCode.UrlElicitationRequired); + expect(error.elicitations).toEqual([elicitationParams]); + } + }); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Tool Registration with _meta field + */ + test('should register tool with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.tool('test-tool', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.registerTool( + 'test-with-meta', + { + description: 'A tool with _meta field', + inputSchema: { name: z.string() }, + _meta: metaData + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'nonexistent-tool' - } - }, - CallToolResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Tool nonexistent-tool not found') - } - ]) - ); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: Tool Registration with _meta field - */ - test('should register tool with _meta field and include it in list response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + 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); }); - const metaData = { - author: 'test-author', - version: '1.2.3', - category: 'utility', - tags: ['test', 'example'] - }; - - mcpServer.registerTool( - 'test-with-meta', - { - description: 'A tool with _meta field', - inputSchema: { name: z.string() }, - _meta: metaData - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + /*** + * Test: Tool Registration without _meta field should have undefined _meta + */ + test('should register tool without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.registerTool( + 'test-without-meta', + { + description: 'A tool without _meta field', + inputSchema: { name: z.string() } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - 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); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: Tool Registration without _meta field should have undefined _meta - */ - test('should register tool without _meta field and have undefined _meta in response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-without-meta'); + expect(result.tools[0]._meta).toBeUndefined(); }); - mcpServer.registerTool( - 'test-without-meta', - { - description: 'A tool without _meta field', - inputSchema: { name: z.string() } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + test('should validate tool names according to SEP specification', () => { + // Create a new server instance for this test + const testServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Spy on console.warn to verify warnings are logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Test valid tool names + testServer.registerTool( + 'valid-tool-name', + { + description: 'A valid tool name' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + // Test tool name with warnings (starts with dash) + testServer.registerTool( + '-warning-tool', + { + description: 'A tool name that generates warnings' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-without-meta'); - expect(result.tools[0]._meta).toBeUndefined(); - }); + // Test invalid tool name (contains spaces) + testServer.registerTool( + 'invalid tool name', + { + description: 'An invalid tool name' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - test('should validate tool names according to SEP specification', () => { - // Create a new server instance for this test - const testServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + // Verify that warnings were issued (both for warnings and validation failures) + expect(warnSpy).toHaveBeenCalled(); - // Spy on console.warn to verify warnings are logged - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - - // Test valid tool names - testServer.registerTool( - 'valid-tool-name', - { - description: 'A valid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test tool name with warnings (starts with dash) - testServer.registerTool( - '-warning-tool', - { - description: 'A tool name that generates warnings' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test invalid tool name (contains spaces) - testServer.registerTool( - 'invalid tool name', - { - description: 'An invalid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Verify that warnings were issued (both for warnings and validation failures) - expect(warnSpy).toHaveBeenCalled(); - - // Verify specific warning content - const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); - expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); - - // Clean up spies - warnSpy.mockRestore(); - }); -}); + // Verify specific warning content + const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); + expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); -describe('resource()', () => { - /*** - * Test: Resource Registration with URI and Read Callback - */ - test('should register resource with uri and readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // Clean up spies + warnSpy.mockRestore(); }); + }); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + describe('resource()', () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ + test('should register resource with uri and readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].name).toBe('test'); - expect(result.resources[0].uri).toBe('test://resource'); - }); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - /*** - * Test: Update Resource with URI - */ - test('should update resource with uri', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.resources).toHaveLength(1); + expect(result.resources[0].name).toBe('test'); + expect(result.resources[0].uri).toBe('test://resource'); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Initial content' - } - ] - })); + /*** + * Test: Update Resource with URI + */ + test('should update resource with uri', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Update the resource - resource.update({ - callback: async () => ({ + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { uri: 'test://resource', - text: 'Updated content' + text: 'Initial content' } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ + // Read the resource and verify we get the updated content + const result = await client.request( { - text: expect.stringContaining('Updated content'), - uri: 'test://resource' - } - ]) - ); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Update Resource Template - */ - test('should update resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource template - const resourceTemplate = mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Initial content' + method: 'resources/read', + params: { + uri: 'test://resource' } - ] - }) - ); + }, + ReadResourceResultSchema + ); - // Update the resource template - resourceTemplate.update({ - callback: async uri => ({ - contents: [ + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ { - uri: uri.href, - text: 'Updated content' + text: expect.stringContaining('Updated content'), + uri: 'test://resource' } - ] - }) - }); + ]) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + /*** + * Test: Update Resource Template + */ + test('should update resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/123' - } - }, - ReadResourceResultSchema - ); + // Register initial resource template + const resourceTemplate = mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Initial content' + } + ] + }) + ); - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Updated content'), - uri: 'test://resource/123' - } - ]) - ); + // Update the resource template + resourceTemplate.update({ + callback: async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Updated content' + } + ] + }) + }); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Resource List Changed Notification - */ - test('should send resource list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ + // Read the resource and verify we get the updated content + const result = await client.request( { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + method: 'resources/read', + params: { + uri: 'test://resource/123' + } + }, + ReadResourceResultSchema + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource/123' + } + ]) + ); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); - expect(notifications).toHaveLength(0); + /*** + * Test: Resource List Changed Notification + */ + test('should send resource list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Now update the resource while connected - resource.update({ - callback: async () => ({ + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { uri: 'test://resource', - text: 'Updated content' + text: 'Test content' } ] - }) - }); + })); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - /*** - * Test: Remove Resource and Send Notification - */ - test('should remove resource and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(notifications).toHaveLength(0); + + // Now update the resource while connected + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial resources - const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] - })); + /*** + * Test: Remove Resource and Send Notification + */ + test('should remove resource and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] - })); + // Register initial resources + const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] + })); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Verify both resources are registered - let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - expect(result.resources).toHaveLength(2); + // Verify both resources are registered + let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); - expect(notifications).toHaveLength(0); + expect(result.resources).toHaveLength(2); - // Remove a resource - resource1.remove(); + expect(notifications).toHaveLength(0); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + // Remove a resource + resource1.remove(); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - // Verify the resource was removed - result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].uri).toBe('test://resource2'); - }); + // Verify the resource was removed + result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); - /*** - * Test: Remove Resource Template and Send Notification - */ - test('should remove resource template and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.resources).toHaveLength(1); + expect(result.resources[0].uri).toBe('test://resource2'); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register resource template - const resourceTemplate = mcpServer.resource( - 'template', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Template content' - } - ] - }) - ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Remove Resource Template and Send Notification + */ + test('should remove resource template and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register resource template + const resourceTemplate = mcpServer.resource( + 'template', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Template content' + } + ] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Verify template is registered - const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - expect(result.resourceTemplates).toHaveLength(1); - expect(notifications).toHaveLength(0); + // Verify template is registered + const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - // Remove the template - resourceTemplate.remove(); + expect(result.resourceTemplates).toHaveLength(1); + expect(notifications).toHaveLength(0); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + // Remove the template + resourceTemplate.remove(); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - // Verify the template was removed - const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - expect(result2.resourceTemplates).toHaveLength(0); - }); + // Verify the template was removed + const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - /*** - * Test: Resource Registration with Metadata - */ - test('should register resource with metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result2.resourceTemplates).toHaveLength(0); }); - const client = new Client({ - name: 'test client', - version: '1.0' + + /*** + * Test: Resource Registration with Metadata + */ + test('should register resource with metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + 'test://resource', + { + description: 'Test resource', + mimeType: 'text/plain' + }, + async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].description).toBe('Test resource'); + expect(result.resources[0].mimeType).toBe('text/plain'); }); - mcpServer.resource( - 'test', - 'test://resource', - { - description: 'Test resource', - mimeType: 'text/plain' - }, - async () => ({ + /*** + * Test: Resource Template Registration + */ + test('should register resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ contents: [ { - uri: 'test://resource', + uri: 'test://resource/123', text: 'Test content' } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].description).toBe('Test resource'); - expect(result.resources[0].mimeType).toBe('text/plain'); - }); + const result = await client.request( + { + method: 'resources/templates/list' + }, + ListResourceTemplatesResultSchema + ); - /*** - * Test: Resource Template Registration - */ - test('should register resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.resourceTemplates).toHaveLength(1); + expect(result.resourceTemplates[0].name).toBe('test'); + expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); }); - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); + /*** + * Test: Resource Template with List Callback + */ + test('should register resource template with listCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + } + ] + }) + }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'resources/templates/list' - }, - ListResourceTemplatesResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.resourceTemplates).toHaveLength(1); - expect(result.resourceTemplates[0].name).toBe('test'); - expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); - }); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - /*** - * Test: Resource Template with List Callback - */ - test('should register resource template with listCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + 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'); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1' - }, + /*** + * Test: Template Variables to Read Callback + */ + test('should pass template variables to readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}/{id}', { + list: undefined + }), + async (uri, { category, id }) => ({ + contents: [ { - name: 'Resource 2', - uri: 'test://resource/2' + uri: uri.href, + text: `Category: ${category}, ID: ${id}` } ] }) - }), - async uri => ({ + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource/books/123' + } + }, + ReadResourceResultSchema + ); + + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Category: books, ID: 123'), + uri: 'test://resource/books/123' + } + ]) + ); + }); + + /*** + * Test: Preventing Duplicate Resource Registration + */ + test('should prevent duplicate resource registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { - uri: uri.href, + uri: 'test://resource', text: 'Test content' } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + expect(() => { + mcpServer.resource('test2', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + /*** + * Test: Multiple Resource Registration + */ + test('should allow registering multiple resources', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - 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'); - }); + // This should succeed + mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [ + { + uri: 'test://resource1', + text: 'Test content 1' + } + ] + })); - /*** - * Test: Template Variables to Read Callback - */ - test('should pass template variables to readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // This should also succeed and not throw about request handlers + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [ + { + uri: 'test://resource2', + text: 'Test content 2' + } + ] + })); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}/{id}', { - list: undefined - }), - async (uri, { category, id }) => ({ + /*** + * Test: Preventing Duplicate Resource Template Registration + */ + test('should prevent duplicate resource template registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ contents: [ { - uri: uri.href, - text: `Category: ${category}, ID: ${id}` + uri: 'test://resource/123', + text: 'Test content' } ] - }) - ); + })); + + expect(() => { + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ + contents: [ + { + uri: 'test://resource/123', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Graceful Resource Read Error Handling + */ + test('should handle resource read errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + mcpServer.resource('error-test', 'test://error', async () => { + throw new Error('Resource read failed'); + }); - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/books/123' - } - }, - ReadResourceResultSchema - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Category: books, ID: 123'), - uri: 'test://resource/books/123' - } - ]) - ); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: Preventing Duplicate Resource Registration - */ - test('should prevent duplicate resource registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://error' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource read failed/); }); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + /*** + * Test: McpError for Invalid Resource URI + */ + test('should throw McpError for invalid resource URI', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(() => { - mcpServer.resource('test2', 'test://resource', async () => ({ + mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { uri: 'test://resource', - text: 'Test content 2' + text: 'Test content' } ] })); - }).toThrow(/already registered/); - }); - - /*** - * Test: Multiple Resource Registration - */ - test('should allow registering multiple resources', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - // This should succeed - mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [ - { - uri: 'test://resource1', - text: 'Test content 1' - } - ] - })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // This should also succeed and not throw about request handlers - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [ - { - uri: 'test://resource2', - text: 'Test content 2' - } - ] - })); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: Preventing Duplicate Resource Template Registration - */ - test('should prevent duplicate resource template registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://nonexistent' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); }); - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a resource template with a complete callback is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(() => { - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content 2' + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] } - ] - })); - }).toThrow(/already registered/); - }); + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - /*** - * Test: Graceful Resource Read Error Handling - */ - test('should handle resource read errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - mcpServer.resource('error-test', 'test://error', async () => { - throw new Error('Resource read failed'); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Resource Template Parameter Completion + */ + test('should support completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { - method: 'resources/read', + method: 'completion/complete', params: { - uri: 'test://error' + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: '' + } } }, - ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource read failed/); - }); + CompleteResultSchema + ); - /*** - * Test: McpError for Invalid Resource URI - */ - test('should throw McpError for invalid resource URI', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.completion.values).toEqual(['books', 'movies', 'music']); + expect(result.completion.total).toBe(3); }); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + /*** + * Test: Filtered Resource Template Parameter Completion + */ + test('should support filtered completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { - method: 'resources/read', + method: 'completion/complete', params: { - uri: 'test://nonexistent' + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: 'm' + } } }, - ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); - }); + CompleteResultSchema + ); - /*** - * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a resource template with a complete callback is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.completion.values).toEqual(['movies', 'music']); + expect(result.completion.total).toBe(2); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); + /*** + * Test: Pass Request ID to Resource Callback + */ + test('should pass requestId to resource callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + let receivedRequestId: string | number | undefined; + mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { + receivedRequestId = extra.requestId; + return { + contents: [ + { + uri: 'test://resource', + text: `Received request ID: ${extra.requestId}` + } + ] + }; + }); - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Resource Template Parameter Completion - */ - test('should support completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource' + } + }, + ReadResourceResultSchema + ); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents).toEqual( + expect.arrayContaining([ { - uri: 'test://resource/test', - text: 'Test content' + text: expect.stringContaining(`Received request ID:`), + uri: 'test://resource' } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + ]) + ); + }); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + describe('prompt()', () => { + /*** + * Test: Zero-Argument Prompt Registration + */ + test('should register zero-argument prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: '' + mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } } - } - }, - CompleteResultSchema - ); + ] + })); - expect(result.completion.values).toEqual(['books', 'movies', 'music']); - expect(result.completion.total).toBe(3); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Filtered Resource Template Parameter Completion - */ - test('should support filtered completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'prompts/list' + }, + ListPromptsResultSchema + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].arguments).toBeUndefined(); + }); + /*** + * Test: Updating Existing Prompt + */ + test('should update existing prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) - } - }), - async () => ({ - contents: [ + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ + messages: [ { - uri: 'test://resource/test', - text: 'Test content' + role: 'assistant', + content: { + type: 'text', + text: 'Initial response' + } } ] - }) - ); + })); + + // Update the prompt + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: 'm' + // Call the prompt and verify we get the updated response + const result = await client.request( + { + method: 'prompts/get', + params: { + name: 'test' } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['movies', 'music']); - expect(result.completion.total).toBe(2); - }); + }, + GetPromptResultSchema + ); - /*** - * Test: Pass Request ID to Resource Callback - */ - test('should pass requestId to resource callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + expect(result.messages).toHaveLength(1); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ]) + ); - const client = new Client({ - name: 'test client', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - let receivedRequestId: string | number | undefined; - mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { - receivedRequestId = extra.requestId; - return { - contents: [ - { - uri: 'test://resource', - text: `Received request ID: ${extra.requestId}` - } - ] + /*** + * Test: Updating Prompt with Schema + */ + test('should update prompt with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Register initial prompt + const prompt = mcpServer.prompt( + 'test', + { + name: z.string() + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Initial: ${name}` + } + } + ] + }) + ); + + // Update the prompt with a different schema + prompt.update({ + argsSchema: { + name: z.string(), + value: z.string() + }, + callback: async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Updated: ${name}, ${value}` + } + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.contents).toEqual( - expect.arrayContaining([ + // Verify the schema was updated + const listResult = await client.request( { - text: expect.stringContaining(`Received request ID:`), - uri: 'test://resource' - } - ]) - ); - }); -}); + method: 'prompts/list' + }, + ListPromptsResultSchema + ); -describe('prompt()', () => { - /*** - * Test: Zero-Argument Prompt Registration - */ - test('should register zero-argument prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + expect(listResult.prompts[0].arguments).toHaveLength(2); + expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); - mcpServer.prompt('test', async () => ({ - messages: [ + // Call the prompt with the new schema + const getResult = await client.request( { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'value' + } } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + }, + GetPromptResultSchema + ); - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + expect(getResult.messages).toHaveLength(1); + expect(getResult.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated: test, value' + } + } + ]) + ); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toBeUndefined(); - }); - /*** - * Test: Updating Existing Prompt - */ - test('should update existing prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Initial response' - } - } - ] - })); + /*** + * Test: Prompt List Changed Notification + */ + test('should send prompt list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Update the prompt - prompt.update({ - callback: async () => ({ + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Updated response' + text: 'Test response' } } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the prompt and verify we get the updated response - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'test' - } - }, - GetPromptResultSchema - ); + expect(notifications).toHaveLength(0); - expect(result.messages).toHaveLength(1); - expect(result.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated response' - } - } - ]) - ); + // Now update the prompt while connected + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - /*** - * Test: Updating Prompt with Schema - */ - test('should update prompt with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompt - const prompt = mcpServer.prompt( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ + + /*** + * Test: Remove Prompt and Send Notification + */ + test('should remove prompt and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompts + const prompt1 = mcpServer.prompt('prompt1', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `Initial: ${name}` + text: 'Prompt 1 response' } } ] - }) - ); - - // Update the prompt with a different schema - prompt.update({ - argsSchema: { - name: z.string(), - value: z.string() - }, - callback: async ({ name, value }) => ({ + })); + + mcpServer.prompt('prompt2', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `Updated: ${name}, ${value}` + text: 'Prompt 2 response' } } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Verify the schema was updated - const listResult = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + // Verify both prompts are registered + let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - expect(listResult.prompts[0].arguments).toHaveLength(2); - expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); + expect(result.prompts).toHaveLength(2); + expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); - // Call the prompt with the new schema - const getResult = await client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'value' - } - } - }, - GetPromptResultSchema - ); + expect(notifications).toHaveLength(0); - expect(getResult.messages).toHaveLength(1); - expect(getResult.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated: test, value' - } - } - ]) - ); + // Remove a prompt + prompt1.remove(); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - /*** - * Test: Prompt List Changed Notification - */ - test('should send prompt list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + + // Verify the prompt was removed + result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('prompt2'); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ + /*** + * Test: Prompt Registration with Arguments Schema + */ + test('should register prompt with args schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test', { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + name: z.string(), + value: z.string() + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(notifications).toHaveLength(0); + const result = await client.request( + { + method: 'prompts/list' + }, + ListPromptsResultSchema + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].arguments).toEqual([ + { name: 'name', required: true }, + { name: 'value', required: true } + ]); + }); + + /*** + * Test: Prompt Registration with Description + */ + test('should register prompt with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Now update the prompt while connected - prompt.update({ - callback: async () => ({ + mcpServer.prompt('test', 'Test description', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Updated response' + text: 'Test response' } } ] - }) - }); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); - }); + })); - /*** - * Test: Remove Prompt and Send Notification - */ - test('should remove prompt and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Register initial prompts - const prompt1 = mcpServer.prompt('prompt1', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 1 response' - } - } - ] - })); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - mcpServer.prompt('prompt2', async () => ({ - messages: [ + const result = await client.request( { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 2 response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify both prompts are registered - let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - - expect(result.prompts).toHaveLength(2); - expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); + method: 'prompts/list' + }, + ListPromptsResultSchema + ); - expect(notifications).toHaveLength(0); + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].description).toBe('Test description'); + }); - // Remove a prompt - prompt1.remove(); + /*** + * Test: Prompt Argument Validation + */ + test('should validate prompt args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + mcpServer.prompt( + 'test', + { + name: z.string(), + value: z.string().min(3) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); - // Verify the prompt was removed - result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('prompt2'); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: Prompt Registration with Arguments Schema - */ - test('should register prompt with args schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'ab' // Too short + } + } + }, + GetPromptResultSchema + ) + ).rejects.toThrow(/Invalid arguments/); }); - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string() - }, - async ({ name, value }) => ({ + /*** + * Test: Preventing Duplicate Prompt Registration + */ + test('should prevent duplicate prompt registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.prompt('test', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `${name}: ${value}` + text: 'Test response' } } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toEqual([ - { name: 'name', required: true }, - { name: 'value', required: true } - ]); - }); + })); - /*** - * Test: Prompt Registration with Description - */ - test('should register prompt with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(() => { + mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }).toThrow(/already registered/); }); - mcpServer.prompt('test', 'Test description', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + /*** + * Test: Multiple Prompt Registration + */ + test('should allow registering multiple prompts', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].description).toBe('Test description'); - }); + // This should succeed + mcpServer.prompt('prompt1', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 1' + } + } + ] + })); - /*** - * Test: Prompt Argument Validation - */ - test('should validate prompt args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // This should also succeed and not throw about request handlers + mcpServer.prompt('prompt2', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Prompt Registration with Arguments + */ + test('should allow registering prompts with arguments', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string().min(3) - }, - async ({ name, value }) => ({ + // This should succeed + mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ messages: [ { - role: 'assistant', + role: 'user', content: { type: 'text', - text: `${name}: ${value}` + text: `Please process this message: ${message}` } } ] - }) - ); + })); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Resources and Prompts with Completion Handlers + */ + test('should allow registering both resources and prompts with completion handlers', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Register a resource with completion + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - await expect( - client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'ab' // Too short + // Register a prompt with completion + mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` } } - }, - GetPromptResultSchema - ) - ).rejects.toThrow(/Invalid arguments/); - }); - - /*** - * Test: Preventing Duplicate Prompt Registration - */ - test('should prevent duplicate prompt registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ] + })); }); - mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + /*** + * Test: McpError for Invalid Prompt Name + */ + test('should throw McpError for invalid prompt name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(() => { - mcpServer.prompt('test', async () => ({ + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt('test-prompt', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Test response 2' + text: 'Test response' } } ] })); - }).toThrow(/already registered/); - }); - /*** - * Test: Multiple Prompt Registration - */ - test('should allow registering multiple prompts', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'nonexistent-prompt' + } + }, + GetPromptResultSchema + ) + ).rejects.toThrow(/Prompt nonexistent-prompt not found/); }); - // This should succeed - mcpServer.prompt('prompt1', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 1' - } - } - ] - })); + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a prompt with a completable argument is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // This should also succeed and not throw about request handlers - mcpServer.prompt('prompt2', async () => ({ - messages: [ + mcpServer.prompt( + 'test-prompt', { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 2' - } - } - ] - })); - }); + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); - /*** - * Test: Prompt Registration with Arguments - */ - test('should allow registering prompts with arguments', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); }); - // This should succeed - mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ - messages: [ + /*** + * Test: Prompt Argument Completion + */ + test('should support completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` - } - } - ] - })); - }); + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); - /*** - * Test: Resources and Prompts with Completion Handlers - */ - test('should allow registering both resources and prompts with completion handlers', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Register a resource with completion - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Register a prompt with completion - mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ - messages: [ + const result = await client.request( { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: '' + } } - } - ] - })); - }); + }, + CompleteResultSchema + ); - /*** - * Test: McpError for Invalid Prompt Name - */ - test('should throw McpError for invalid prompt name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.completion.total).toBe(3); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Filtered Prompt Argument Completion + */ + test('should support filtered completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.prompt('test-prompt', async () => ({ - messages: [ + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { - method: 'prompts/get', + method: 'completion/complete', params: { - name: 'nonexistent-prompt' + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + } } }, - GetPromptResultSchema - ) - ).rejects.toThrow(/Prompt nonexistent-prompt not found/); - }); + CompleteResultSchema + ); - /*** - * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a prompt with a completable argument is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.completion.values).toEqual(['Alice']); + expect(result.completion.total).toBe(1); }); - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` - } - } - ] - }) - ); + /*** + * Test: Pass Request ID to Prompt Callback + */ + test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + let receivedRequestId: string | number | undefined; + mcpServer.prompt('request-id-test', async extra => { + receivedRequestId = extra.requestId; + return { + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Received request ID: ${extra.requestId}` + } + } + ] + }; + }); - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Prompt Argument Completion - */ - test('should support completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'prompts/get', + params: { + name: 'request-id-test' + } + }, + GetPromptResultSchema + ); - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages).toEqual( + expect.arrayContaining([ { role: 'assistant', content: { type: 'text', - text: `Hello ${name}` + text: expect.stringContaining(`Received request ID:`) } } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: '' - } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); - expect(result.completion.total).toBe(3); - }); - - /*** - * Test: Filtered Prompt Argument Completion - */ - test('should support filtered completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ]) + ); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Resource Template Metadata Priority + */ + test('should prioritize individual resource metadata over template metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1', + description: 'Individual resource description', + mimeType: 'text/plain' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + // This resource has no description or mimeType + } + ] + }) + }), + { + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + ] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - } - } - }, - CompleteResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.completion.values).toEqual(['Alice']); - expect(result.completion.total).toBe(1); - }); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - /*** - * Test: Pass Request ID to Prompt Callback - */ - test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + expect(result.resources).toHaveLength(2); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // 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'); - let receivedRequestId: string | number | undefined; - mcpServer.prompt('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Received request ID: ${extra.requestId}` - } - } - ] - }; + // 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'); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test('should allow resource to override all template metadata fields', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'request-id-test' - } - }, - GetPromptResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.messages).toEqual( - expect.arrayContaining([ + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Overridden Name', + uri: 'test://resource/1', + description: 'Overridden description', + mimeType: 'text/markdown' + // Add any other metadata fields if they exist + } + ] + }) + }), { - role: 'assistant', - content: { - type: 'text', - text: expect.stringContaining(`Received request ID:`) - } - } - ]) - ); - }); - - /*** - * Test: Resource Template Metadata Priority - */ - test('should prioritize individual resource metadata over template metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1', - description: 'Individual resource description', - mimeType: 'text/plain' - }, + title: 'Template Name', + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ { - name: 'Resource 2', - uri: 'test://resource/2' - // This resource has no description or mimeType + uri: uri.href, + text: 'Test content' } ] }) - }), - { - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(2); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - // 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).toHaveLength(1); - // 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'); + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe('Overridden Name'); + expect(result.resources[0].description).toBe('Overridden description'); + expect(result.resources[0].mimeType).toBe('text/markdown'); + }); }); - /*** - * Test: Resource Template Metadata Overrides All Fields - */ - test('should allow resource to override all template metadata fields', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + describe('Tool title precedence', () => { + test('should follow correct title precedence: title → annotations.title → name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Overridden Name', - uri: 'test://resource/1', - description: 'Overridden description', - mimeType: 'text/markdown' - // Add any other metadata fields if they exist - } - ] + // Tool 1: Only name + mcpServer.tool('tool_name_only', async () => ({ + content: [{ type: 'text', text: 'Response' }] + })); + + // Tool 2: Name and annotations.title + mcpServer.tool( + 'tool_with_annotations_title', + 'Tool with annotations title', + { + title: 'Annotations Title' + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] }) - }), - { - title: 'Template Name', - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' + ); + + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + 'tool_with_title', + { + title: 'Regular Title', + description: 'Tool with regular title' + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Response' }] + }) + ); + + // Tool 4: All three - title should win + mcpServer.registerTool( + 'tool_with_all_titles', + { + title: 'Regular Title Wins', + description: 'Tool with all titles', + annotations: { + title: 'Annotations Title Should Not Show' } - ] - }) - ); + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Response' }] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + expect(result.tools).toHaveLength(4); - expect(result.resources).toHaveLength(1); + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === 'tool_name_only'); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe('tool_name_only'); - // 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'); - }); -}); + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe('Annotations Title'); + expect(getDisplayName(tool2!)).toBe('Annotations Title'); -describe('Tool title precedence', () => { - test('should follow correct title precedence: title → annotations.title → name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === 'tool_with_title'); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe('Regular Title'); + expect(getDisplayName(tool3!)).toBe('Regular Title'); + + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe('Regular Title Wins'); + expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); + expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); }); - // Tool 1: Only name - mcpServer.tool('tool_name_only', async () => ({ - content: [{ type: 'text', text: 'Response' }] - })); - - // Tool 2: Name and annotations.title - mcpServer.tool( - 'tool_with_annotations_title', - 'Tool with annotations title', - { - title: 'Annotations Title' - }, - async () => ({ - content: [{ type: 'text', text: 'Response' }] - }) - ); - - // Tool 3: Name and title (using registerTool) - mcpServer.registerTool( - 'tool_with_title', - { - title: 'Regular Title', - description: 'Tool with regular title' - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - // Tool 4: All three - title should win - mcpServer.registerTool( - 'tool_with_all_titles', - { - title: 'Regular Title Wins', - description: 'Tool with all titles', - annotations: { - title: 'Annotations Title Should Not Show' - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(4); - - // Tool 1: Only name - should display name - const tool1 = result.tools.find(t => t.name === 'tool_name_only'); - expect(tool1).toBeDefined(); - expect(getDisplayName(tool1!)).toBe('tool_name_only'); - - // Tool 2: Name and annotations.title - should display annotations.title - const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); - expect(tool2).toBeDefined(); - expect(tool2!.annotations?.title).toBe('Annotations Title'); - expect(getDisplayName(tool2!)).toBe('Annotations Title'); - - // Tool 3: Name and title - should display title - const tool3 = result.tools.find(t => t.name === 'tool_with_title'); - expect(tool3).toBeDefined(); - expect(tool3!.title).toBe('Regular Title'); - expect(getDisplayName(tool3!)).toBe('Regular Title'); - - // Tool 4: All three - title should take precedence - const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); - expect(tool4).toBeDefined(); - expect(tool4!.title).toBe('Regular Title Wins'); - expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); - expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); - }); + test('getDisplayName unit tests for title precedence', () => { + // Test 1: Only name + expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); - test('getDisplayName unit tests for title precedence', () => { - // Test 1: Only name - expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); - - // Test 2: Name and title - title wins - expect( - getDisplayName({ - name: 'tool_name', - title: 'Tool Title' - }) - ).toBe('Tool Title'); - - // Test 3: Name and annotations.title - annotations.title wins - expect( - getDisplayName({ - name: 'tool_name', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 4: All three - title wins (correct precedence) - expect( - getDisplayName({ - name: 'tool_name', - title: 'Regular Title', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Regular Title'); - - // Test 5: Empty title should not be used - expect( - getDisplayName({ - name: 'tool_name', - title: '', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 6: Undefined vs null handling - expect( - getDisplayName({ - name: 'tool_name', - title: undefined, - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - }); + // Test 2: Name and title - title wins + expect( + getDisplayName({ + name: 'tool_name', + title: 'Tool Title' + }) + ).toBe('Tool Title'); - test('should support resource template completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // Test 3: Name and annotations.title - annotations.title wins + expect( + getDisplayName({ + name: 'tool_name', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 4: All three - title wins (correct precedence) + expect( + getDisplayName({ + name: 'tool_name', + title: 'Regular Title', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Regular Title'); + + // Test 5: Empty title should not be used + expect( + getDisplayName({ + name: 'tool_name', + title: '', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 6: Undefined vs null handling + expect( + getDisplayName({ + name: 'tool_name', + title: undefined, + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + test('should support resource template completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.registerResource( - 'test', - new ResourceTemplate('github://repos/{owner}/{repo}', { - list: undefined, - complete: { - repo: (value, context) => { - if (context?.arguments?.['owner'] === 'org1') { - return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); - } else if (context?.arguments?.['owner'] === 'org2') { - return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + mcpServer.registerResource( + 'test', + new ResourceTemplate('github://repos/{owner}/{repo}', { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.['owner'] === 'org1') { + return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); + } else if (context?.arguments?.['owner'] === 'org2') { + return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + } + return []; } - return []; - } - } - }), - { - title: 'GitHub Repository', - description: 'Repository information' - }, - async () => ({ - contents: [ - { - uri: 'github://repos/test/test', - text: 'Test content' } - ] - }) - ); + }), + { + title: 'GitHub Repository', + description: 'Repository information' + }, + async () => ({ + contents: [ + { + uri: 'github://repos/test/test', + text: 'Test content' + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Test with microsoft owner - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'p' - }, - context: { - arguments: { - owner: 'org1' + // Test with microsoft owner + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'p' + }, + context: { + arguments: { + owner: 'org1' + } } } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); - expect(result1.completion.total).toBe(3); - - // Test with facebook owner - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'r' - }, - context: { - arguments: { - owner: 'org2' + }, + CompleteResultSchema + ); + + expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); + expect(result1.completion.total).toBe(3); + + // Test with facebook owner + const result2 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'r' + }, + context: { + arguments: { + owner: 'org2' + } } } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); - expect(result2.completion.total).toBe(3); - - // Test with no resolved context - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 't' + }, + CompleteResultSchema + ); + + expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); + expect(result2.completion.total).toBe(3); + + // Test with no resolved context + const result3 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 't' + } } - } - }, - CompleteResultSchema - ); + }, + CompleteResultSchema + ); - expect(result3.completion.values).toEqual([]); - expect(result3.completion.total).toBe(0); - }); + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); + + test('should support prompt argument completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + title: 'Team Greeting', + description: 'Generate a greeting for team members', + argsSchema: { + department: completable(z.string(), value => { + return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + const department = context?.arguments?.['department']; + if (department === 'engineering') { + return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); + } else if (department === 'sales') { + return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); + } else if (department === 'marketing') { + return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); + } + return ['Guest'].filter(n => n.startsWith(value)); + }) + } + }, + async ({ department, name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}, welcome to the ${department} team!` + } + } + ] + }) + ); - test('should support prompt argument completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - mcpServer.registerPrompt( - 'test-prompt', - { - title: 'Team Greeting', - description: 'Generate a greeting for team members', - argsSchema: { - department: completable(z.string(), value => { - return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); - }), - name: completable(z.string(), (value, context) => { - const department = context?.arguments?.['department']; - if (department === 'engineering') { - return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); - } else if (department === 'sales') { - return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); - } else if (department === 'marketing') { - return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); - } - return ['Guest'].filter(n => n.startsWith(value)); - }) - } - }, - async ({ department, name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}, welcome to the ${department} team!` + // Test with engineering department + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + }, + context: { + arguments: { + department: 'engineering' + } } } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }, + CompleteResultSchema + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + expect(result1.completion.values).toEqual(['Alice']); - // Test with engineering department - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - }, - context: { - arguments: { - department: 'engineering' + // Test with sales department + const result2 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'D' + }, + context: { + arguments: { + department: 'sales' + } } } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['Alice']); - - // Test with sales department - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'D' - }, - context: { - arguments: { - department: 'sales' + }, + CompleteResultSchema + ); + + expect(result2.completion.values).toEqual(['David']); + + // Test with marketing department + const result3 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + }, + context: { + arguments: { + department: 'marketing' + } } } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['David']); - - // Test with marketing department - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - }, - context: { - arguments: { - department: 'marketing' + }, + CompleteResultSchema + ); + + expect(result3.completion.values).toEqual(['Grace']); + + // Test with no resolved context + const result4 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' } } - } - }, - CompleteResultSchema - ); - - expect(result3.completion.values).toEqual(['Grace']); - - // Test with no resolved context - const result4 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - } - } - }, - CompleteResultSchema - ); + }, + CompleteResultSchema + ); - expect(result4.completion.values).toEqual(['Guest']); + expect(result4.completion.values).toEqual(['Guest']); + }); }); -}); -describe('elicitInput()', () => { - const checkAvailability = jest.fn().mockResolvedValue(false); - const findAlternatives = jest.fn().mockResolvedValue([]); - const makeBooking = jest.fn().mockResolvedValue('BOOKING-123'); + describe('elicitInput()', () => { + const checkAvailability = vi.fn().mockResolvedValue(false); + const findAlternatives = vi.fn().mockResolvedValue([]); + const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); - let mcpServer: McpServer; - let client: Client; + let mcpServer: McpServer; + let client: Client; - beforeEach(() => { - jest.clearAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); - // Create server with restaurant booking tool - mcpServer = new McpServer({ - name: 'restaurant-booking-server', - version: '1.0.0' - }); + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0' + }); - // Register the restaurant booking tool from README example - mcpServer.tool( - 'book-restaurant', - { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: 'object', - properties: { - checkAlternatives: { - type: 'boolean', - title: 'Check alternative dates', - description: 'Would you like me to check other dates?' + // Register the restaurant booking tool from README example + mcpServer.tool( + 'book-restaurant', + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: 'object', + properties: { + checkAlternatives: { + type: 'boolean', + title: 'Check alternative dates', + description: 'Would you like me to check other dates?' + }, + flexibleDates: { + type: 'string', + title: 'Date flexibility', + description: 'How flexible are your dates?', + enum: ['next_day', 'same_week', 'next_week'], + enumNames: ['Next day', 'Same week', 'Next week'] + } }, - flexibleDates: { - type: 'string', - title: 'Date flexibility', - description: 'How flexible are your dates?', - enum: ['next_day', 'same_week', 'next_week'], - enumNames: ['Next day', 'Same week', 'Next week'] - } - }, - required: ['checkAlternatives'] + required: ['checkAlternatives'] + } + }); + + if (result.action === 'accept' && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [ + { + type: 'text', + text: `Found these alternatives: ${alternatives.join(', ')}` + } + ] + }; } - }); - if (result.action === 'accept' && result.content?.checkAlternatives) { - const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string); return { content: [ { type: 'text', - text: `Found these alternatives: ${alternatives.join(', ')}` + text: 'No booking made. Original date not available.' } ] }; } + await makeBooking(restaurant, date, partySize); return { content: [ { type: 'text', - text: 'No booking made. Original date not available.' + text: `Booked table for ${partySize} at ${restaurant} on ${date}` } ] }; } + ); + + // Create client with elicitation capability + client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + }); + + test('should successfully elicit additional information', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); - await makeBooking(restaurant, date, partySize); + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async request => { + expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); return { - content: [ - { - type: 'text', - text: `Booked table for ${partySize} at ${restaurant} on ${date}` - } - ] + action: 'accept', + content: { + checkAlternatives: true, + flexibleDates: 'same_week' + } }; - } - ); + }); - // Create client with elicitation capability - client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 } - } - ); - }); + }); - test('should successfully elicit additional information', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); - - // Set up client to accept alternative date checking - client.setRequestHandler(ElicitRequestSchema, async request => { - expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); - return { - action: 'accept', - content: { - checkAlternatives: true, - flexibleDates: 'same_week' + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' } - }; + ]); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + test('should handle user declining to elicitation request', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Set up client to reject alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'accept', + content: { + checkAlternatives: false + } + }; + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' - } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - test('should handle user declining to elicitation request', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); - // Set up client to reject alternative date checking - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'accept', - content: { - checkAlternatives: false + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' } - }; + ]); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + test('should handle user cancelling the elicitation', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'cancel' + }; + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - test('should handle user cancelling the elicitation', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); - // Set up client to cancel the elicitation - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'cancel' - }; + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); }); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + describe('Tools with union and intersection schemas', () => { + test('should support union schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); -}); + const unionSchema = z.union([ + z.object({ type: z.literal('email'), email: z.string().email() }), + z.object({ type: z.literal('phone'), phone: z.string() }) + ]); -describe('Tools with union and intersection schemas', () => { - test('should support union schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + server.registerTool('contact', { inputSchema: unionSchema }, async args => { + if (args.type === 'email') { + return { + content: [{ type: 'text', text: `Email contact: ${args.email}` }] + }; + } else { + return { + content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] + }; + } + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); - const unionSchema = z.union([ - z.object({ type: z.literal('email'), email: z.string().email() }), - z.object({ type: z.literal('phone'), phone: z.string() }) - ]); + const emailResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'email', + email: 'test@example.com' + } + }); - server.registerTool('contact', { inputSchema: unionSchema }, async args => { - if (args.type === 'email') { - return { - content: [{ type: 'text', text: `Email contact: ${args.email}` }] - }; - } else { - return { - content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] - }; - } - }); + expect(emailResult.content).toEqual([ + { + type: 'text', + text: 'Email contact: test@example.com' + } + ]); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); + const phoneResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'phone', + phone: '+1234567890' + } + }); - const emailResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'email', - email: 'test@example.com' - } + expect(phoneResult.content).toEqual([ + { + type: 'text', + text: 'Phone contact: +1234567890' + } + ]); }); - expect(emailResult.content).toEqual([ - { - type: 'text', - text: 'Email contact: test@example.com' - } - ]); - - const phoneResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'phone', - phone: '+1234567890' - } - }); + test('should support intersection schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - expect(phoneResult.content).toEqual([ - { - type: 'text', - text: 'Phone contact: +1234567890' - } - ]); - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - test('should support intersection schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const baseSchema = z.object({ id: z.string() }); + const extendedSchema = z.object({ name: z.string(), age: z.number() }); + const intersectionSchema = z.intersection(baseSchema, extendedSchema); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + server.registerTool('user', { inputSchema: intersectionSchema }, async args => { + return { + content: [ + { + type: 'text', + text: `User: ${args.id}, ${args.name}, ${args.age} years old` + } + ] + }; + }); - const baseSchema = z.object({ id: z.string() }); - const extendedSchema = z.object({ name: z.string(), age: z.number() }); - const intersectionSchema = z.intersection(baseSchema, extendedSchema); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); - server.registerTool('user', { inputSchema: intersectionSchema }, async args => { - return { - content: [ - { - type: 'text', - text: `User: ${args.id}, ${args.name}, ${args.age} years old` - } - ] - }; - }); + const result = await client.callTool({ + name: 'user', + arguments: { + id: '123', + name: 'John Doe', + age: 30 + } + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'user', - arguments: { - id: '123', - name: 'John Doe', - age: 30 - } + expect(result.content).toEqual([ + { + type: 'text', + text: 'User: 123, John Doe, 30 years old' + } + ]); }); - expect(result.content).toEqual([ - { - type: 'text', - text: 'User: 123, John Doe, 30 years old' - } - ]); - }); + test('should support complex nested schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - test('should support complex nested schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const schema = z.object({ + items: z.array( + z.union([ + z.object({ type: z.literal('text'), content: z.string() }), + z.object({ type: z.literal('number'), value: z.number() }) + ]) + ) + }); - const schema = z.object({ - items: z.array( - z.union([ - z.object({ type: z.literal('text'), content: z.string() }), - z.object({ type: z.literal('number'), value: z.number() }) - ]) - ) - }); + server.registerTool('process', { inputSchema: schema }, async args => { + const processed = args.items.map(item => { + if (item.type === 'text') { + return item.content.toUpperCase(); + } else { + return item.value * 2; + } + }); + return { + content: [ + { + type: 'text', + text: `Processed: ${processed.join(', ')}` + } + ] + }; + }); - server.registerTool('process', { inputSchema: schema }, async args => { - const processed = args.items.map(item => { - if (item.type === 'text') { - return item.content.toUpperCase(); - } else { - return item.value * 2; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'process', + arguments: { + items: [ + { type: 'text', content: 'hello' }, + { type: 'number', value: 5 }, + { type: 'text', content: 'world' } + ] } }); - return { - content: [ - { - type: 'text', - text: `Processed: ${processed.join(', ')}` - } - ] - }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'process', - arguments: { - items: [ - { type: 'text', content: 'hello' }, - { type: 'number', value: 5 }, - { type: 'text', content: 'world' } - ] - } + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: HELLO, 10, WORLD' + } + ]); }); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: HELLO, 10, WORLD' - } - ]); - }); + test('should validate union schema inputs correctly', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - test('should validate union schema inputs correctly', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const unionSchema = z.union([ + z.object({ type: z.literal('a'), value: z.string() }), + z.object({ type: z.literal('b'), value: z.number() }) + ]); - const unionSchema = z.union([ - z.object({ type: z.literal('a'), value: z.string() }), - z.object({ type: z.literal('b'), value: z.number() }) - ]); + server.registerTool('union-test', { inputSchema: unionSchema }, async () => { + return { + content: [{ type: 'text', text: 'Success' }] + }; + }); - server.registerTool('union-test', { inputSchema: unionSchema }, async () => { - return { - content: [{ type: 'text', text: 'Success' }] - }; - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const invalidTypeResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); + expect(invalidTypeResult.isError).toBe(true); + expect(invalidTypeResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); - const invalidTypeResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'a', - value: 123 - } - }); + const invalidDiscriminatorResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'c', + value: 'test' + } + }); - expect(invalidTypeResult.isError).toBe(true); - expect(invalidTypeResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); - - const invalidDiscriminatorResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'c', - value: 'test' - } + expect(invalidDiscriminatorResult.isError).toBe(true); + expect(invalidDiscriminatorResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); }); - - expect(invalidDiscriminatorResult.isError).toBe(true); - expect(invalidDiscriminatorResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); }); }); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index bee3b76ec..b9b6d5596 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1,6 +1,20 @@ import { Server, ServerOptions } from './index.js'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { z, ZodRawShape, ZodObject, ZodString, ZodTypeAny, ZodType, ZodTypeDef, ZodOptional } from 'zod'; +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, @@ -36,7 +50,7 @@ import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate } from '../types.js'; -import { Completable, CompletableDef } from './completable.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'; @@ -87,8 +101,8 @@ export class McpServer { return; } - this.server.assertCanSetRequestHandler(ListToolsRequestSchema.shape.method.value); - this.server.assertCanSetRequestHandler(CallToolRequestSchema.shape.method.value); + this.server.assertCanSetRequestHandler(getMethodValue(ListToolsRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(CallToolRequestSchema)); this.server.registerCapabilities({ tools: { @@ -106,21 +120,27 @@ export class McpServer { name, title: tool.title, description: tool.description, - inputSchema: tool.inputSchema - ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - pipeStrategy: 'input' - }) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA, + inputSchema: (() => { + const obj = normalizeObjectSchema(tool.inputSchema); + return obj + ? (toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: 'input' + }) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA; + })(), annotations: tool.annotations, _meta: tool._meta }; if (tool.outputSchema) { - toolDefinition.outputSchema = zodToJsonSchema(tool.outputSchema, { - strictUnions: true, - pipeStrategy: 'output' - }) as Tool['outputSchema']; + const obj = normalizeObjectSchema(tool.outputSchema); + if (obj) { + toolDefinition.outputSchema = toJsonSchemaCompat(obj, { + strictUnions: true, + pipeStrategy: 'output' + }) as Tool['outputSchema']; + } } return toolDefinition; @@ -143,12 +163,16 @@ export class McpServer { } if (tool.inputSchema) { - const cb = tool.callback as ToolCallback; - const parseResult = await tool.inputSchema.safeParseAsync(request.params.arguments); + const cb = tool.callback as ToolCallback; + // 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, request.params.arguments); if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Input validation error: Invalid arguments for tool ${request.params.name}: ${parseResult.error.message}` + `Input validation error: Invalid arguments for tool ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } @@ -169,15 +193,21 @@ export class McpServer { } // if the tool has an output schema, validate structured content - const parseResult = await tool.outputSchema.safeParseAsync(result.structuredContent); + const outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema; + const parseResult = await safeParseAsync(outputObj, result.structuredContent); if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${request.params.name}: ${parseResult.error.message}` + `Output validation error: Invalid structured content for tool ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } } } 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)); } @@ -212,7 +242,7 @@ export class McpServer { return; } - this.server.assertCanSetRequestHandler(CompleteRequestSchema.shape.method.value); + this.server.assertCanSetRequestHandler(getMethodValue(CompleteRequestSchema)); this.server.registerCapabilities({ completions: {} @@ -250,13 +280,17 @@ export class McpServer { return EMPTY_COMPLETION_RESULT; } - const field = prompt.argsSchema.shape[request.params.argument.name]; - if (!(field instanceof Completable)) { + const promptShape = getObjectShape(prompt.argsSchema); + const field = promptShape?.[request.params.argument.name]; + if (!isCompletable(field)) { return EMPTY_COMPLETION_RESULT; } - const def: CompletableDef = field._def; - const suggestions = await def.complete(request.params.argument.value, request.params.context); + const completer = getCompleter(field); + if (!completer) { + return EMPTY_COMPLETION_RESULT; + } + const suggestions = await completer(request.params.argument.value, request.params.context); return createCompletionResult(suggestions); } @@ -291,9 +325,9 @@ export class McpServer { return; } - this.server.assertCanSetRequestHandler(ListResourcesRequestSchema.shape.method.value); - this.server.assertCanSetRequestHandler(ListResourceTemplatesRequestSchema.shape.method.value); - this.server.assertCanSetRequestHandler(ReadResourceRequestSchema.shape.method.value); + this.server.assertCanSetRequestHandler(getMethodValue(ListResourcesRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(ListResourceTemplatesRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(ReadResourceRequestSchema)); this.server.registerCapabilities({ resources: { @@ -374,8 +408,8 @@ export class McpServer { return; } - this.server.assertCanSetRequestHandler(ListPromptsRequestSchema.shape.method.value); - this.server.assertCanSetRequestHandler(GetPromptRequestSchema.shape.method.value); + this.server.assertCanSetRequestHandler(getMethodValue(ListPromptsRequestSchema)); + this.server.assertCanSetRequestHandler(getMethodValue(GetPromptRequestSchema)); this.server.registerCapabilities({ prompts: { @@ -410,11 +444,12 @@ export class McpServer { } if (prompt.argsSchema) { - const parseResult = await prompt.argsSchema.safeParseAsync(request.params.arguments); + const argsObj = normalizeObjectSchema(prompt.argsSchema) as AnyObjectSchema; + const parseResult = await safeParseAsync(argsObj, request.params.arguments); if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Invalid arguments for prompt ${request.params.name}: ${parseResult.error.message}` + `Invalid arguments for prompt ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } @@ -632,7 +667,7 @@ export class McpServer { const registeredPrompt: RegisteredPrompt = { title, description, - argsSchema: argsSchema === undefined ? undefined : z.object(argsSchema), + argsSchema: argsSchema === undefined ? undefined : objectFromShape(argsSchema), callback, enabled: true, disable: () => registeredPrompt.update({ enabled: false }), @@ -645,7 +680,7 @@ export class McpServer { } 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 = z.object(updates.argsSchema); + 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(); @@ -659,11 +694,11 @@ export class McpServer { name: string, title: string | undefined, description: string | undefined, - inputSchema: ZodRawShape | ZodType | undefined, - outputSchema: ZodRawShape | ZodType | undefined, + inputSchema: ZodRawShapeCompat | AnySchema | undefined, + outputSchema: ZodRawShapeCompat | AnySchema | undefined, annotations: ToolAnnotations | undefined, _meta: Record | undefined, - callback: ToolCallback + callback: ToolCallback ): RegisteredTool { // Validate tool name according to SEP specification validateAndWarnToolName(name); @@ -690,7 +725,7 @@ export class McpServer { } 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 = z.object(updates.paramsSchema); + if (typeof updates.paramsSchema !== 'undefined') registeredTool.inputSchema = objectFromShape(updates.paramsSchema); if (typeof updates.callback !== 'undefined') registeredTool.callback = updates.callback; if (typeof updates.annotations !== 'undefined') registeredTool.annotations = updates.annotations; if (typeof updates._meta !== 'undefined') registeredTool._meta = updates._meta; @@ -726,7 +761,11 @@ export class McpServer { * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. * @deprecated Use `registerTool` instead. */ - tool(name: string, paramsSchemaOrAnnotations: Args | ToolAnnotations, cb: ToolCallback): RegisteredTool; + tool( + name: string, + paramsSchemaOrAnnotations: Args | ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; /** * Registers a tool `name` (with a description) taking either parameter schema or annotations. @@ -737,7 +776,7 @@ export class McpServer { * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. * @deprecated Use `registerTool` instead. */ - tool( + tool( name: string, description: string, paramsSchemaOrAnnotations: Args | ToolAnnotations, @@ -748,13 +787,18 @@ export class McpServer { * Registers a tool with both parameter schema and annotations. * @deprecated Use `registerTool` instead. */ - tool(name: string, paramsSchema: Args, annotations: ToolAnnotations, cb: ToolCallback): RegisteredTool; + tool( + name: string, + paramsSchema: Args, + annotations: ToolAnnotations, + cb: ToolCallback + ): RegisteredTool; /** * Registers a tool with description, parameter schema, and annotations. * @deprecated Use `registerTool` instead. */ - tool( + tool( name: string, description: string, paramsSchema: Args, @@ -771,8 +815,8 @@ export class McpServer { } let description: string | undefined; - let inputSchema: ZodRawShape | undefined; - let outputSchema: ZodRawShape | undefined; + let inputSchema: ZodRawShapeCompat | undefined; + let outputSchema: ZodRawShapeCompat | undefined; let annotations: ToolAnnotations | undefined; // Tool properties are passed as separate arguments, with omissions allowed. @@ -790,7 +834,7 @@ export class McpServer { if (isZodRawShape(firstArg)) { // We have a params schema as the first arg - inputSchema = rest.shift() as ZodRawShape; + inputSchema = rest.shift() as ZodRawShapeCompat; // Check if the next arg is potentially annotations if (rest.length > 1 && typeof rest[0] === 'object' && rest[0] !== null && !isZodRawShape(rest[0])) { @@ -805,7 +849,7 @@ export class McpServer { annotations = rest.shift() as ToolAnnotations; } } - const callback = rest[0] as ToolCallback; + const callback = rest[0] as ToolCallback; return this._createRegisteredTool(name, undefined, description, inputSchema, outputSchema, annotations, undefined, callback); } @@ -813,7 +857,7 @@ export class McpServer { /** * Registers a tool with a config object and callback. */ - registerTool, OutputArgs extends ZodRawShape | ZodType>( + registerTool( name: string, config: { title?: string; @@ -839,7 +883,7 @@ export class McpServer { outputSchema, annotations, _meta, - cb as ToolCallback + cb as ToolCallback ); } @@ -1042,27 +1086,27 @@ export class ResourceTemplate { * - `content` if the tool does not have an outputSchema * - Both fields are optional but typically one should be provided */ -export type ToolCallback = undefined> = Args extends ZodRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra - ) => CallToolResult | Promise - : Args extends ZodType - ? (args: T, extra: RequestHandlerExtra) => CallToolResult | Promise +export type ToolCallback = Args extends ZodRawShapeCompat + ? (args: ShapeOutput, extra: RequestHandlerExtra) => CallToolResult | Promise + : Args extends AnySchema + ? ( + args: SchemaOutput, + extra: RequestHandlerExtra + ) => CallToolResult | Promise : (extra: RequestHandlerExtra) => CallToolResult | Promise; export type RegisteredTool = { title?: string; description?: string; - inputSchema?: ZodType; - outputSchema?: ZodType; + inputSchema?: AnySchema; + outputSchema?: AnySchema; annotations?: ToolAnnotations; _meta?: Record; - callback: ToolCallback; + callback: ToolCallback; enabled: boolean; enable(): void; disable(): void; - update(updates: { + update(updates: { name?: string | null; title?: string; description?: string; @@ -1081,8 +1125,8 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; -// Helper to check if an object is a Zod schema (ZodRawShape) -function isZodRawShape(obj: unknown): obj is ZodRawShape { +// Helper to check if an object is a Zod schema (ZodRawShapeCompat) +function isZodRawShape(obj: unknown): obj is ZodRawShapeCompat { if (typeof obj !== 'object' || obj === null) return false; const isEmptyObject = Object.keys(obj).length === 0; @@ -1092,7 +1136,7 @@ function isZodRawShape(obj: unknown): obj is ZodRawShape { return isEmptyObject || Object.values(obj as object).some(isZodTypeLike); } -function isZodTypeLike(value: unknown): value is ZodType { +function isZodTypeLike(value: unknown): value is AnySchema { return ( value !== null && typeof value === 'object' && @@ -1107,13 +1151,13 @@ function isZodTypeLike(value: unknown): value is ZodType { * Converts a provided Zod schema to a Zod object if it is a ZodRawShape, * otherwise returns the schema as is. */ -function getZodSchemaObject(schema: ZodRawShape | ZodType | undefined): ZodType | undefined { +function getZodSchemaObject(schema: ZodRawShapeCompat | AnySchema | undefined): AnySchema | undefined { if (!schema) { return undefined; } if (isZodRawShape(schema)) { - return z.object(schema); + return objectFromShape(schema); } return schema; @@ -1186,21 +1230,16 @@ export type RegisteredResourceTemplate = { remove(): void; }; -type PromptArgsRawShape = { - [k: string]: ZodType | ZodOptional>; -}; +type PromptArgsRawShape = ZodRawShapeCompat; export type PromptCallback = Args extends PromptArgsRawShape - ? ( - args: z.objectOutputType, - extra: RequestHandlerExtra - ) => GetPromptResult | Promise + ? (args: ShapeOutput, extra: RequestHandlerExtra) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { title?: string; description?: string; - argsSchema?: ZodObject; + argsSchema?: AnyObjectSchema; callback: PromptCallback; enabled: boolean; enable(): void; @@ -1216,14 +1255,36 @@ export type RegisteredPrompt = { remove(): void; }; -function promptArgumentsFromSchema(schema: ZodObject): PromptArgument[] { - return Object.entries(schema.shape).map( - ([name, field]): PromptArgument => ({ +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: field.description, - required: !field.isOptional() - }) - ); + 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 { diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 418094de2..5879b76ea 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -1,21 +1,22 @@ import http from 'http'; -import { jest } from '@jest/globals'; +import { type Mocked } from 'vitest'; + import { SSEServerTransport } from './sse.js'; import { McpServer } from './mcp.js'; import { createServer, type Server } from 'node:http'; import { AddressInfo } from 'node:net'; -import { z } from 'zod'; import { CallToolResult, JSONRPCMessage } from '../types.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; const createMockResponse = () => { const res = { - writeHead: jest.fn().mockReturnThis(), - write: jest.fn().mockReturnThis(), - on: jest.fn().mockReturnThis(), - end: jest.fn().mockReturnThis() + writeHead: vi.fn().mockReturnThis(), + write: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis() }; - return res as unknown as jest.Mocked; + return res as unknown as Mocked; }; const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { @@ -25,7 +26,7 @@ const createMockRequest = ({ headers = {}, body }: { headers?: Record().mockImplementation((event, listener) => { + on: vi.fn().mockImplementation((event, listener) => { const mockListener = listener as unknown as (...args: unknown[]) => void; if (event === 'data') { mockListener(Buffer.from(body || '') as unknown as Error); @@ -41,63 +42,13 @@ const createMockRequest = ({ headers = {}, body }: { headers?: Record(), - removeListener: jest.fn() + listeners: vi.fn(), + removeListener: vi.fn() } as unknown as http.IncomingMessage; return mockReq; }; -/** - * Helper to create and start test HTTP server with MCP setup - */ -async function createTestServerWithSse(args: { mockRes: http.ServerResponse }): Promise<{ - server: Server; - transport: SSEServerTransport; - mcpServer: McpServer; - baseUrl: URL; - sessionId: string; - serverPort: number; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'greet', - 'A simple greeting tool', - { name: z.string().describe('Name to greet') }, - async ({ name }): Promise => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } - ); - - const endpoint = '/messages'; - - const transport = new SSEServerTransport(endpoint, args.mockRes); - const sessionId = transport.sessionId; - - await mcpServer.connect(transport); - - const server = createServer(async (req, res) => { - try { - await transport.handlePostMessage(req, res); - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - const port = (server.address() as AddressInfo).port; - - return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; -} - async function readAllSSEEvents(response: Response): Promise { const reader = response.body?.getReader(); if (!reader) throw new Error('No readable stream'); @@ -147,260 +98,289 @@ async function sendSsePostRequest( }); } -describe('SSEServerTransport', () => { - async function initializeServer(baseUrl: URL): Promise { - const response = await sendSsePostRequest(baseUrl, { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26', - capabilities: {} - }, +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + + /** + * Helper to create and start test HTTP server with MCP setup + */ + async function createTestServerWithSse(args: { mockRes: http.ServerResponse }): Promise<{ + server: Server; + transport: SSEServerTransport; + mcpServer: McpServer; + baseUrl: URL; + sessionId: string; + serverPort: number; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { name: z.string().describe('Name to greet') }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + const endpoint = '/messages'; - id: 'init-1' - } as JSONRPCMessage); + const transport = new SSEServerTransport(endpoint, args.mockRes); + const sessionId = transport.sessionId; + + await mcpServer.connect(transport); + + const server = createServer(async (req, res) => { + try { + await transport.handlePostMessage(req, res); + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); - expect(response.status).toBe(202); + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); - const text = await readAllSSEEvents(response); + const port = (server.address() as AddressInfo).port; - expect(text).toHaveLength(1); - expect(text[0]).toBe('Accepted'); + return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; } - describe('start method', () => { - it('should correctly append sessionId to a simple relative endpoint', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + describe('SSEServerTransport', () => { + async function initializeServer(baseUrl: URL): Promise { + const response = await sendSsePostRequest(baseUrl, { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26', + capabilities: {} + }, - await transport.start(); + id: 'init-1' + } as JSONRPCMessage); - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n`); - }); + expect(response.status).toBe(202); - it('should correctly append sessionId to an endpoint with existing query parameters', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages?foo=bar&baz=qux'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + const text = await readAllSSEEvents(response); - await transport.start(); + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith( - `event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n` - ); - }); + describe('start method', () => { + it('should correctly append sessionId to a simple relative endpoint', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - it('should correctly append sessionId to an endpoint with a hash fragment', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages#section1'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + await transport.start(); - await transport.start(); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n`); + }); - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n`); - }); + it('should correctly append sessionId to an endpoint with existing query parameters', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages?foo=bar&baz=qux'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages?key=value#section2'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + await transport.start(); - await transport.start(); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n` + ); + }); - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith( - `event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n` - ); - }); + it('should correctly append sessionId to an endpoint with a hash fragment', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages#section1'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - it('should correctly handle the root path endpoint "/"', async () => { - const mockRes = createMockResponse(); - const endpoint = '/'; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + await transport.start(); - await transport.start(); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n`); + }); - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); - }); + it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages?key=value#section2'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - it('should correctly handle an empty string endpoint ""', async () => { - const mockRes = createMockResponse(); - const endpoint = ''; - const transport = new SSEServerTransport(endpoint, mockRes); - const expectedSessionId = transport.sessionId; + await transport.start(); - await transport.start(); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith( + `event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n` + ); + }); - expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); - }); + it('should correctly handle the root path endpoint "/"', async () => { + const mockRes = createMockResponse(); + const endpoint = '/'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - /** - * Test: Tool With Request Info - */ - it('should pass request info to tool callback', async () => { - const mockRes = createMockResponse(); - const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); - await initializeServer(baseUrl); - - mcpServer.tool( - 'test-request-info', - 'A simple test tool with request info', - { name: z.string().describe('Name to greet') }, - async ({ name }, { requestInfo }): Promise => { - return { - content: [ - { type: 'text', text: `Hello, ${name}!` }, - { type: 'text', text: `${JSON.stringify(requestInfo)}` } - ] - }; - } - ); + await transport.start(); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'test-request-info', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); + }); - const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + it('should correctly handle an empty string endpoint ""', async () => { + const mockRes = createMockResponse(); + const endpoint = ''; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - expect(response.status).toBe(202); + await transport.start(); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); + }); - const expectedMessage = { - result: { - content: [ - { - type: 'text', - text: 'Hello, Test User!' - }, - { - type: 'text', - text: JSON.stringify({ - headers: { - host: `127.0.0.1:${serverPort}`, - connection: 'keep-alive', - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'accept-language': '*', - 'sec-fetch-mode': 'cors', - 'user-agent': 'node', - 'accept-encoding': 'gzip, deflate', - 'content-length': '124' - } - }) + /** + * Test: Tool With Request Info + */ + it('should pass request info to tool callback', async () => { + const mockRes = createMockResponse(); + const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes }); + await initializeServer(baseUrl); + + mcpServer.tool( + 'test-request-info', + 'A simple test tool with request info', + { name: z.string().describe('Name to greet') }, + async ({ name }, { requestInfo }): Promise => { + return { + content: [ + { type: 'text', text: `Hello, ${name}!` }, + { type: 'text', text: `${JSON.stringify(requestInfo)}` } + ] + }; + } + ); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-request-info', + arguments: { + name: 'Test User' } - ] - }, - jsonrpc: '2.0', - id: 'call-1' - }; - expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); - }); - }); + }, + id: 'call-1' + }; - describe('handlePostMessage method', () => { - it('should return 500 if server has not started', async () => { - const mockReq = createMockRequest(); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - - const error = 'SSE connection not established'; - await expect(transport.handlePostMessage(mockReq, mockRes)).rejects.toThrow(error); - expect(mockRes.writeHead).toHaveBeenCalledWith(500); - expect(mockRes.end).toHaveBeenCalledWith(error); - }); + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); - it('should return 400 if content-type is not application/json', async () => { - const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onerror = jest.fn(); - const error = 'Unsupported content-type: text/plain'; - await expect(transport.handlePostMessage(mockReq, mockRes)).resolves.toBe(undefined); - expect(mockRes.writeHead).toHaveBeenCalledWith(400); - expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); - expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); - }); + expect(response.status).toBe(202); - it('should return 400 if message has not a valid schema', async () => { - const invalidMessage = JSON.stringify({ - // missing jsonrpc field - method: 'call', - params: [1, 2, 3], - id: 1 - }); - const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, - body: invalidMessage + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + + const expectedMessage = { + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + }, + { + type: 'text', + text: JSON.stringify({ + headers: { + host: `127.0.0.1:${serverPort}`, + connection: 'keep-alive', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'node', + 'accept-encoding': 'gzip, deflate', + 'content-length': '124' + } + }) + } + ] + }, + jsonrpc: '2.0', + id: 'call-1' + }; + expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onmessage = jest.fn(); - await transport.handlePostMessage(mockReq, mockRes); - expect(mockRes.writeHead).toHaveBeenCalledWith(400); - expect(transport.onmessage).not.toHaveBeenCalled(); - expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); }); - it('should return 202 if message has a valid schema', async () => { - const validMessage = JSON.stringify({ - jsonrpc: '2.0', - method: 'call', - params: { - a: 1, - b: 2, - c: 3 - }, - id: 1 + describe('handlePostMessage method', () => { + it('should return 500 if server has not started', async () => { + const mockReq = createMockRequest(); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + + const error = 'SSE connection not established'; + await expect(transport.handlePostMessage(mockReq, mockRes)).rejects.toThrow(error); + expect(mockRes.writeHead).toHaveBeenCalledWith(500); + expect(mockRes.end).toHaveBeenCalledWith(error); }); - const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, - body: validMessage + + it('should return 400 if content-type is not application/json', async () => { + const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onerror = vi.fn(); + const error = 'Unsupported content-type: text/plain'; + await expect(transport.handlePostMessage(mockReq, mockRes)).resolves.toBe(undefined); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error)); + expect(transport.onerror).toHaveBeenCalledWith(new Error(error)); + }); + + it('should return 400 if message has not a valid schema', async () => { + const invalidMessage = JSON.stringify({ + // missing jsonrpc field + method: 'call', + params: [1, 2, 3], + id: 1 + }); + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: invalidMessage + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = vi.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(400); + expect(transport.onmessage).not.toHaveBeenCalled(); + expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`); }); - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - - transport.onmessage = jest.fn(); - await transport.handlePostMessage(mockReq, mockRes); - expect(mockRes.writeHead).toHaveBeenCalledWith(202); - expect(mockRes.end).toHaveBeenCalledWith('Accepted'); - expect(transport.onmessage).toHaveBeenCalledWith( - { + + it('should return 202 if message has a valid schema', async () => { + const validMessage = JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { @@ -409,301 +389,326 @@ describe('SSEServerTransport', () => { c: 3 }, id: 1 - }, - { - authInfo: { - token: 'test-token' + }); + const mockReq = createMockRequest({ + headers: { 'content-type': 'application/json' }, + body: validMessage + }); + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + + transport.onmessage = vi.fn(); + await transport.handlePostMessage(mockReq, mockRes); + expect(mockRes.writeHead).toHaveBeenCalledWith(202); + expect(mockRes.end).toHaveBeenCalledWith('Accepted'); + expect(transport.onmessage).toHaveBeenCalledWith( + { + jsonrpc: '2.0', + method: 'call', + params: { + a: 1, + b: 2, + c: 3 + }, + id: 1 }, - requestInfo: { - headers: { - 'content-type': 'application/json' + { + authInfo: { + token: 'test-token' + }, + requestInfo: { + headers: { + 'content-type': 'application/json' + } } } - } - ); - }); - }); - - describe('close method', () => { - it('should call onclose', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - transport.onclose = jest.fn(); - await transport.close(); - expect(transport.onclose).toHaveBeenCalled(); - }); - }); - - describe('send method', () => { - it('should call onsend', async () => { - const mockRes = createMockResponse(); - const endpoint = '/messages'; - const transport = new SSEServerTransport(endpoint, mockRes); - await transport.start(); - expect(mockRes.write).toHaveBeenCalledTimes(1); - expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: endpoint')); - expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); + ); + }); }); - }); - describe('DNS rebinding protection', () => { - beforeEach(() => { - jest.clearAllMocks(); + describe('close method', () => { + it('should call onclose', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); + await transport.start(); + transport.onclose = vi.fn(); + await transport.close(); + expect(transport.onclose).toHaveBeenCalled(); + }); }); - describe('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { + describe('send method', () => { + it('should call onsend', async () => { const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000', 'example.com'], - enableDnsRebindingProtection: true - }); + const endpoint = '/messages'; + const transport = new SSEServerTransport(endpoint, mockRes); await transport.start(); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: endpoint')); + expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`)); + }); + }); - const mockReq = createMockRequest({ - headers: { - host: 'localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); + describe('DNS rebinding protection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000', 'example.com'], + enableDnsRebindingProtection: true + }); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + host: 'localhost:3000', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - it('should reject requests with disallowed host headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should reject requests with disallowed host headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true + }); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); - }); + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - it('should reject requests without host header when allowedHosts is configured', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - enableDnsRebindingProtection: true - }); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should reject requests without host header when allowedHosts is configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + enableDnsRebindingProtection: true + }); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); - }); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - describe('Origin header validation', () => { - it('should accept requests with allowed origin headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedOrigins: ['http://localhost:3000/', 'https://example.com/'], - enableDnsRebindingProtection: true - }); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - origin: 'http://localhost:3000/', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000/', 'https://example.com/'], + enableDnsRebindingProtection: true + }); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + origin: 'http://localhost:3000/', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - it('should reject requests with disallowed origin headers', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedOrigins: ['http://localhost:3000/'], - enableDnsRebindingProtection: true - }); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - origin: 'http://evil.com/', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should reject requests with disallowed origin headers', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedOrigins: ['http://localhost:3000/'], + enableDnsRebindingProtection: true + }); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); - }); - }); + const mockReq = createMockRequest({ + headers: { + origin: 'http://evil.com/', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - describe('Content-Type validation', () => { - it('should accept requests with application/json content-type', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + describe('Content-Type validation', () => { + it('should accept requests with application/json content-type', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - it('should accept requests with application/json with charset', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json; charset=utf-8' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }); + const mockHandleRes = createMockResponse(); - it('should reject requests with non-application/json content-type when protection is enabled', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'text/plain' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should reject requests with non-application/json content-type when protection is enabled', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); - }); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'text/plain' + } + }); + const mockHandleRes = createMockResponse(); - describe('enableDnsRebindingProtection option', () => { - it('should skip all validations when enableDnsRebindingProtection is false', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - allowedOrigins: ['http://localhost:3000/'], - enableDnsRebindingProtection: false - }); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - origin: 'http://evil.com/', - 'content-type': 'text/plain' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000/'], + enableDnsRebindingProtection: false + }); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + origin: 'http://evil.com/', + 'content-type': 'text/plain' + } + }); + const mockHandleRes = createMockResponse(); - // Should pass even with invalid headers because protection is disabled - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - // The error should be from content-type parsing, not DNS rebinding protection - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); - }); - }); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - describe('Combined validations', () => { - it('should validate both host and origin when both are configured', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000'], - allowedOrigins: ['http://localhost:3000/'], - enableDnsRebindingProtection: true + // Should pass even with invalid headers because protection is disabled + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + // The error should be from content-type parsing, not DNS rebinding protection + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); }); - await transport.start(); + }); - // Valid host, invalid origin - const mockReq1 = createMockRequest({ - headers: { - host: 'localhost:3000', - origin: 'http://evil.com/', - 'content-type': 'application/json' - } - }); - const mockHandleRes1 = createMockResponse(); + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes, { + allowedHosts: ['localhost:3000'], + allowedOrigins: ['http://localhost:3000/'], + enableDnsRebindingProtection: true + }); + await transport.start(); + + // Valid host, invalid origin + const mockReq1 = createMockRequest({ + headers: { + host: 'localhost:3000', + origin: 'http://evil.com/', + 'content-type': 'application/json' + } + }); + const mockHandleRes1 = createMockResponse(); - await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); + await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' }); - expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); - // Invalid host, valid origin - const mockReq2 = createMockRequest({ - headers: { - host: 'evil.com', - origin: 'http://localhost:3000/', - 'content-type': 'application/json' - } - }); - const mockHandleRes2 = createMockResponse(); + // Invalid host, valid origin + const mockReq2 = createMockRequest({ + headers: { + host: 'evil.com', + origin: 'http://localhost:3000/', + 'content-type': 'application/json' + } + }); + const mockHandleRes2 = createMockResponse(); - await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); - expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); - // Both valid - const mockReq3 = createMockRequest({ - headers: { - host: 'localhost:3000', - origin: 'http://localhost:3000/', - 'content-type': 'application/json' - } - }); - const mockHandleRes3 = createMockResponse(); + // Both valid + const mockReq3 = createMockRequest({ + headers: { + host: 'localhost:3000', + origin: 'http://localhost:3000/', + 'content-type': 'application/json' + } + }); + const mockHandleRes3 = createMockResponse(); - await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); - expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); }); }); }); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 824d0f423..80ee04d67 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -4,8 +4,8 @@ import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from './streamableHttp.js'; import { McpServer } from './mcp.js'; import { CallToolResult, JSONRPCMessage } from '../types.js'; -import { z } from 'zod'; import { AuthInfo } from './auth/types.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; async function getFreePort() { return new Promise(res => { @@ -31,113 +31,7 @@ interface TestServerConfig { eventStore?: EventStore; onsessioninitialized?: (sessionId: string) => void | Promise; onsessionclosed?: (sessionId: string) => void | Promise; -} - -/** - * Helper to create and start test HTTP server with MCP setup - */ -async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'greet', - 'A simple greeting tool', - { name: z.string().describe('Name to greet') }, - async ({ name }): Promise => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - return { server, transport, mcpServer, baseUrl }; -} - -/** - * Helper to create and start authenticated test HTTP server with MCP setup - */ -async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'profile', - 'A user profile data tool', - { active: z.boolean().describe('Profile status') }, - async ({ active }, { authInfo }): Promise => { - return { content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); - }); - }); - - return { server, transport, mcpServer, baseUrl }; + retryInterval?: number; } /** @@ -223,1863 +117,2261 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag }) }); } +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + /** + * Helper to create and start test HTTP server with MCP setup + */ + async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); -describe('StreamableHTTPServerTransport', () => { - let server: Server; - let mcpServer: McpServer; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestServer(); - server = result.server; - transport = result.transport; - mcpServer = result.mcpServer; - baseUrl = result.baseUrl; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } - - it('should initialize server and generate session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); - expect(response.headers.get('mcp-session-id')).toBeDefined(); - }); - - it('should reject second initialization request', async () => { - // First initialize - const sessionId = await initializeServer(); - expect(sessionId).toBeDefined(); - - // Try second initialize - const secondInitMessage = { - ...TEST_MESSAGES.initialize, - id: 'second-init' - }; - - const response = await sendPostRequest(baseUrl, secondInitMessage); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Server already initialized/); - }); - - it('should reject batch initialize request', async () => { - const batchInitMessages: JSONRPCMessage[] = [ - TEST_MESSAGES.initialize, - { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client-2', version: '1.0' }, - protocolVersion: '2025-03-26' - }, - id: 'init-2' + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { name: z.string().describe('Name to greet') }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; } - ]; - - const response = await sendPostRequest(baseUrl, batchInitMessages); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); - }); - - it('should handle post requests via sse response correctly', async () => { - sessionId = await initializeServer(); - - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - - expect(response.status).toBe(200); - - // Read the SSE stream for the response - const text = await readSSEEvent(response); - - // Parse the SSE event - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); + ); - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: 'greet', - description: 'A simple greeting tool' - }) - ]) - }), - id: 'tools-1' + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed, + retryInterval: config.retryInterval }); - }); - it('should call a tool and return the result', async () => { - sessionId = await initializeServer(); + await mcpServer.connect(transport); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Test User' + const server = createServer(async (req, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + await transport.handleRequest(req, res); } - }, - id: 'call-1' - }; + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Hello, Test User!' - } - ] - }, - id: 'call-1' + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); }); - }); - /*** - * Test: Tool With Request Info + return { server, transport, mcpServer, baseUrl }; + } + + /** + * Helper to create and start authenticated test HTTP server with MCP setup */ - it('should pass request info to tool callback', async () => { - sessionId = await initializeServer(); + async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); mcpServer.tool( - 'test-request-info', - 'A simple test tool with request info', - { name: z.string().describe('Name to greet') }, - async ({ name }, { requestInfo }): Promise => { - return { - content: [ - { type: 'text', text: `Hello, ${name}!` }, - { type: 'text', text: `${JSON.stringify(requestInfo)}` } - ] - }; + 'profile', + 'A user profile data tool', + { active: z.boolean().describe('Profile status') }, + async ({ active }, { authInfo }): Promise => { + return { content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; } ); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'test-request-info', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; - - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { type: 'text', text: 'Hello, Test User!' }, - { type: 'text', text: expect.any(String) } - ] - }, - id: 'call-1' - }); - - const requestInfo = JSON.parse(eventData.result.content[1].text); - expect(requestInfo).toMatchObject({ - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - connection: 'keep-alive', - 'mcp-session-id': sessionId, - 'accept-language': '*', - 'user-agent': expect.any(String), - 'accept-encoding': expect.any(String), - 'content-length': expect.any(String) - } + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed }); - }); - - it('should reject requests without a valid session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request/); - expect(errorData.id).toBeNull(); - }); - - it('should reject invalid session ID', async () => { - // First initialize to be in valid state - await initializeServer(); - // Now try with invalid session ID - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); + await mcpServer.connect(transport); - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); - - it('should establish standalone SSE stream and receive server-initiated messages', async () => { - // First initialize to get a session ID - sessionId = await initializeServer(); - - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' + const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; + await transport.handleRequest(req, res); + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); } }); - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - - // Send a notification (server-initiated message) that should appear on SSE stream - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } - }; + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); - // Send the notification via transport - await transport.send(notification); + return { server, transport, mcpServer, baseUrl }; + } - // Read from the stream and verify we got the notification - const text = await readSSEEvent(sseResponse); + const { z } = entry; + describe('StreamableHTTPServerTransport', () => { + let server: Server; + let mcpServer: McpServer; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + mcpServer = result.mcpServer; + baseUrl = result.baseUrl; + }); - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } + afterEach(async () => { + await stopTestServer({ server, transport }); }); - }); - it('should not close GET SSE stream after sending multiple server notifications', async () => { - sessionId = await initializeServer(); + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } - expect(sseResponse.status).toBe(200); - const reader = sseResponse.body?.getReader(); + it('should initialize server and generate session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Send multiple notifications - const notification1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('mcp-session-id')).toBeDefined(); + }); - // Just send one and verify it comes through - then the stream should stay open - await transport.send(notification1); + it('should reject second initialization request', async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); - const { value, done } = await reader!.read(); - const text = new TextDecoder().decode(value); - expect(text).toContain('First notification'); - expect(done).toBe(false); // Stream should still be open - }); + // Try second initialize + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: 'second-init' + }; - it('should reject second SSE stream for the same session', async () => { - sessionId = await initializeServer(); + const response = await sendPostRequest(baseUrl, secondInitMessage); - // Open first SSE stream - const firstStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); + }); + + it('should reject batch initialize request', async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client-2', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-2' + } + ]; - expect(firstStream.status).toBe(200); + const response = await sendPostRequest(baseUrl, batchInitMessages); - // Try to open a second SSE stream with the same session ID - const secondStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); }); - // Should be rejected - expect(secondStream.status).toBe(409); // Conflict - const errorData = await secondStream.json(); - expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); - }); + it('should handle post requests via sse response correctly', async () => { + sessionId = await initializeServer(); - it('should reject GET requests without Accept: text/event-stream header', async () => { - sessionId = await initializeServer(); + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - // Try GET without proper Accept header - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(200); - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); - }); + // Read the SSE stream for the response + const text = await readSSEEvent(response); - it('should reject POST requests without proper Accept header', async () => { - sessionId = await initializeServer(); + // Parse the SSE event + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - // Try POST without Accept: text/event-stream - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', // Missing text/event-stream - 'mcp-session-id': sessionId - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'greet', + description: 'A simple greeting tool' + }) + ]) + }), + id: 'tools-1' + }); }); - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); - }); + it('should call a tool and return the result', async () => { + sessionId = await initializeServer(); - it('should reject unsupported Content-Type', async () => { - sessionId = await initializeServer(); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; - // Try POST with text/plain Content-Type - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is plain text' - }); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); - expect(response.status).toBe(415); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); - }); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - it('should handle JSON-RPC batch notification messages with 202 response', async () => { - sessionId = await initializeServer(); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + } + ] + }, + id: 'call-1' + }); + }); - // Send batch of notifications (no IDs) - const batchNotifications: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'someNotification1', params: {} }, - { jsonrpc: '2.0', method: 'someNotification2', params: {} } - ]; - const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + /*** + * Test: Tool With Request Info + */ + it('should pass request info to tool callback', async () => { + sessionId = await initializeServer(); - expect(response.status).toBe(202); - }); + mcpServer.tool( + 'test-request-info', + 'A simple test tool with request info', + { name: z.string().describe('Name to greet') }, + async ({ name }, { requestInfo }): Promise => { + return { + content: [ + { type: 'text', text: `Hello, ${name}!` }, + { type: 'text', text: `${JSON.stringify(requestInfo)}` } + ] + }; + } + ); - it('should handle batch request messages with SSE stream for responses', async () => { - sessionId = await initializeServer(); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-request-info', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; - // Send batch of requests - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } - ]; - const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - const reader = response.body?.getReader(); + const eventData = JSON.parse(dataLine!.substring(5)); - // The responses may come in any order or together in one chunk - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { type: 'text', text: 'Hello, Test User!' }, + { type: 'text', text: expect.any(String) } + ] + }, + id: 'call-1' + }); - // Check that both responses were sent on the same stream - expect(text).toContain('"id":"req-1"'); - expect(text).toContain('"tools"'); // tools/list result - expect(text).toContain('"id":"req-2"'); - expect(text).toContain('Hello, BatchUser'); // tools/call result - }); + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String) + } + }); + }); - it('should properly handle invalid JSON data', async () => { - sessionId = await initializeServer(); + it('should reject requests without a valid session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - // Send invalid JSON - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is not valid JSON' + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request/); + expect(errorData.id).toBeNull(); }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32700, /Parse error/); - }); - - it('should return 400 error for invalid JSON-RPC messages', async () => { - sessionId = await initializeServer(); + it('should reject invalid session ID', async () => { + // First initialize to be in valid state + await initializeServer(); - // Invalid JSON-RPC (missing required jsonrpc version) - const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version - const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toMatchObject({ - jsonrpc: '2.0', - error: expect.anything() + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); }); - }); - it('should reject requests to uninitialized server', async () => { - // Create a new HTTP server and transport without initializing - const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); - // Transport not used in test but needed for cleanup - - // No initialization, just send a request directly - const uninitializedMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'uninitialized-test' - }; + it('should establish standalone SSE stream and receive server-initiated messages', async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); - // Send a request to uninitialized server - const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Server not initialized/); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - // Cleanup - await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); - }); + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }; - it('should send response messages to the connection that sent the request', async () => { - sessionId = await initializeServer(); + // Send the notification via transport + await transport.send(notification); - const message1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'req-1' - }; + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); - const message2: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'Connection2' } - }, - id: 'req-2' - }; + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - // Make two concurrent fetch connections for different requests - const req1 = sendPostRequest(baseUrl, message1, sessionId); - const req2 = sendPostRequest(baseUrl, message2, sessionId); - - // Get both responses - const [response1, response2] = await Promise.all([req1, req2]); - const reader1 = response1.body?.getReader(); - const reader2 = response2.body?.getReader(); - - // Read responses from each stream (requires each receives its specific response) - const { value: value1 } = await reader1!.read(); - const text1 = new TextDecoder().decode(value1); - expect(text1).toContain('"id":"req-1"'); - expect(text1).toContain('"tools"'); // tools/list result - - const { value: value2 } = await reader2!.read(); - const text2 = new TextDecoder().decode(value2); - expect(text2).toContain('"id":"req-2"'); - expect(text2).toContain('Hello, Connection2'); // tools/call result - }); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }); + }); - it('should keep stream open after sending server notifications', async () => { - sessionId = await initializeServer(); + it('should not close GET SSE stream after sending multiple server notifications', async () => { + sessionId = await initializeServer(); - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - // Send several server-initiated notifications - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }); + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Second notification' } - }); + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }; - // Stream should still be open - it should not close after sending notifications - expect(sseResponse.bodyUsed).toBe(false); - }); + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); - // The current implementation will close the entire transport for DELETE - // Creating a temporary transport/server where we don't care if it gets closed - it('should properly handle DELETE requests and close session', async () => { - // Setup a temporary server for this test - const tempResult = await createTestServer(); - const tempServer = tempResult.server; - const tempUrl = tempResult.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Now DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('First notification'); + expect(done).toBe(false); // Stream should still be open }); - expect(deleteResponse.status).toBe(200); + it('should reject second SSE stream for the same session', async () => { + sessionId = await initializeServer(); - // Clean up - don't wait indefinitely for server close - tempServer.close(); - }); + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - it('should reject DELETE requests with invalid session ID', async () => { - // Initialize the server first to activate it - sessionId = await initializeServer(); + expect(firstStream.status).toBe(200); - // Try to delete with invalid session ID - const response = await fetch(baseUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } - }); + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); + }); - describe('protocol version header validation', () => { - it('should accept requests with matching protocol version', async () => { + it('should reject GET requests without Accept: text/event-stream header', async () => { sessionId = await initializeServer(); - // Send request with matching protocol version - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(200); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); }); - it('should accept requests without protocol version header', async () => { + it('should reject POST requests without proper Accept header', async () => { sessionId = await initializeServer(); - // Send request without protocol version header + // Try POST without Accept: text/event-stream const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', + Accept: 'application/json', // Missing text/event-stream 'mcp-session-id': sessionId - // No mcp-protocol-version header }, body: JSON.stringify(TEST_MESSAGES.toolsList) }); - expect(response.status).toBe(200); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); }); - it('should reject requests with unsupported protocol version', async () => { + it('should reject unsupported Content-Type', async () => { sessionId = await initializeServer(); - // Send request with unsupported protocol version + // Try POST with text/plain Content-Type const response = await fetch(baseUrl, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'text/plain', Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '1999-01-01' // Unsupported version + 'mcp-session-id': sessionId }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + body: 'This is plain text' }); - expect(response.status).toBe(400); + expect(response.status).toBe(415); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); + }); + + it('should handle JSON-RPC batch notification messages with 202 response', async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'someNotification1', params: {} }, + { jsonrpc: '2.0', method: 'someNotification2', params: {} } + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); }); - it('should accept when protocol version differs from negotiated version', async () => { + it('should handle batch request messages with SSE stream for responses', async () => { sessionId = await initializeServer(); - // Spy on console.warn to verify warning is logged - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); - // Send request with different but supported protocol version + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it('should properly handle invalid JSON data', async () => { + sessionId = await initializeServer(); + + // Send invalid JSON const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2024-11-05' // Different but supported version + 'mcp-session-id': sessionId }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + body: 'This is not valid JSON' }); - // Request should still succeed - expect(response.status).toBe(200); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32700, /Parse error/); + }); + + it('should return 400 error for invalid JSON-RPC messages', async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: '2.0', + error: expect.anything() + }); + }); + + it('should reject requests to uninitialized server', async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'uninitialized-test' + }; + + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); - warnSpy.mockRestore(); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); }); - it('should handle protocol version validation for GET requests', async () => { + it('should send response messages to the connection that sent the request', async () => { sessionId = await initializeServer(); - // GET request with unsupported protocol version - const response = await fetch(baseUrl, { + const message1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'req-1' + }; + + const message2: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'Connection2' } + }, + id: 'req-2' + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it('should keep stream open after sending server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { method: 'GET', headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' + 'mcp-protocol-version': '2025-03-26' } }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + // Send several server-initiated notifications + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }); + + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Second notification' } + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); }); - it('should handle protocol version validation for DELETE requests', async () => { + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it('should properly handle DELETE requests and close session', async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up - don't wait indefinitely for server close + tempServer.close(); + }); + + it('should reject DELETE requests with invalid session ID', async () => { + // Initialize the server first to activate it sessionId = await initializeServer(); - // DELETE request with unsupported protocol version + // Try to delete with invalid session ID const response = await fetch(baseUrl, { method: 'DELETE', headers: { - 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-03-26' } }); - expect(response.status).toBe(400); + expect(response.status).toBe(404); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + expectErrorResponse(errorData, -32001, /Session not found/); }); - }); -}); -describe('StreamableHTTPServerTransport with AuthInfo', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestAuthServer(); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); + describe('protocol version header validation', () => { + it('should accept requests with matching protocol version', async () => { + sessionId = await initializeServer(); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + expect(response.status).toBe(200); + }); - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } + it('should accept requests without protocol version header', async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(200); + }); - it('should call a tool with authInfo', async () => { - sessionId = await initializeServer(); + it('should reject requests with unsupported protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: true } - }, - id: 'call-1' - }; + it('should accept when protocol version differs from negotiated version', async () => { + sessionId = await initializeServer(); + + // Spy on console.warn to verify warning is logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Send request with different but supported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2024-11-05' // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Active profile from token: test-token!' + // Request should still succeed + expect(response.status).toBe(200); + + warnSpy.mockRestore(); + }); + + it('should handle protocol version validation for GET requests', async () => { + sessionId = await initializeServer(); + + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'invalid-version' } - ] - }, - id: 'call-1' - }); - }); + }); - it('should calls tool without authInfo when it is optional', async () => { - sessionId = await initializeServer(); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: false } - }, - id: 'call-1' - }; + it('should handle protocol version validation for DELETE requests', async () => { + sessionId = await initializeServer(); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Inactive profile from token: undefined!' + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'invalid-version' } - ] - }, - id: 'call-1' + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); }); }); -}); -// Test JSON Response Mode -describe('StreamableHTTPServerTransport with JSON Response Mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; + describe('StreamableHTTPServerTransport with AuthInfo', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + beforeEach(async () => { + const result = await createTestAuthServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + afterEach(async () => { + await stopTestServer({ server, transport }); + }); - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } - it('should return JSON response for a single request', async () => { - const toolsListMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'json-req-1' - }; + it('should call a tool with authInfo', async () => { + sessionId = await initializeServer(); - const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: true } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); + expect(response.status).toBe(200); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - const result = await response.json(); - expect(result).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) - }), - id: 'json-req-1' + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Active profile from token: test-token!' + } + ] + }, + id: 'call-1' + }); + }); + + it('should calls tool without authInfo when it is optional', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: false } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Inactive profile from token: undefined!' + } + ] + }, + id: 'call-1' + }); }); }); - it('should return JSON response for batch requests', async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } - ]; + // Test JSON Response Mode + describe('StreamableHTTPServerTransport with JSON Response Mode', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); + afterEach(async () => { + await stopTestServer({ server, transport }); + }); - const results = await response.json(); - expect(Array.isArray(results)).toBe(true); - expect(results).toHaveLength(2); + it('should return JSON response for a single request', async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'json-req-1' + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); - // Batch responses can come in any order - const listResponse = results.find((r: { id?: string }) => r.id === 'batch-1'); - const callResponse = results.find((r: { id?: string }) => r.id === 'batch-2'); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); - expect(listResponse).toEqual( - expect.objectContaining({ + const result = await response.json(); + expect(result).toMatchObject({ jsonrpc: '2.0', - id: 'batch-1', result: expect.objectContaining({ tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }), + id: 'json-req-1' + }); + }); + + it('should return JSON response for batch requests', async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } + ]; + + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const results = await response.json(); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: string }) => r.id === 'batch-1'); + const callResponse = results.find((r: { id?: string }) => r.id === 'batch-2'); + + expect(listResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-1', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }) }) - }) - ); + ); + + expect(callResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-2', + result: expect.objectContaining({ + content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) + }) + }) + ); + }); + }); + + // Test pre-parsed body handling + describe('StreamableHTTPServerTransport with pre-parsed body', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody !== null) { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }, + sessionIdGenerator: () => randomUUID() + }); - expect(callResponse).toEqual( - expect.objectContaining({ + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should accept pre-parsed request body', async () => { + // Set up the pre-parsed body + parsedBody = { jsonrpc: '2.0', - id: 'batch-2', - result: expect.objectContaining({ - content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) + method: 'tools/list', + params: {}, + id: 'preparsed-1' + }; + + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + // Empty body - we're testing pre-parsed body + body: '' + }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); + }); + + it('should handle pre-parsed batch messages', async () => { + parsedBody = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } + ]; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: '' // Empty as we're using pre-parsed + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); + }); + + it('should prefer pre-parsed body over request body', async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-wins' + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Ignored' } }, + id: 'ignored-id' }) - }) - ); + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); }); -}); -// Test pre-parsed body handling -describe('StreamableHTTPServerTransport with pre-parsed body', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let parsedBody: unknown = null; - - beforeEach(async () => { - const result = await createTestServer({ - customRequestHandler: async (req, res) => { - try { - if (parsedBody !== null) { - await transport.handleRequest(req, res, parsedBody); - parsedBody = null; // Reset after use - } else { - await transport.handleRequest(req, res); + // Test resumability support + describe('StreamableHTTPServerTransport with resumability', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); + + // Simple implementation of EventStore + const eventStore: EventStore = { + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, + + async replayEventsAfter( + lastEventId: EventId, + { + send + }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + } + ): Promise { + const streamId = lastEventId.split('_')[0]; + // Extract stream ID from the event ID + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + await send(eventId, message); } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); } - }, - sessionIdGenerator: () => randomUUID() + return streamId; + } + }; + + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; + + // Verify resumability is enabled on the transport + expect(transport['_eventStore']).toBeDefined(); + + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); + it('should store and include event IDs in server SSE messages', async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - it('should accept pre-parsed request body', async () => { - // Set up the pre-parsed body - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-1' - }; + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification with event ID' } + }; - // Send an empty body since we'll use pre-parsed body - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - // Empty body - we're testing pre-parsed body - body: '' + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('_GET_stream')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); }); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); + it('should store and replay MCP server tool notifications', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(sseResponse.status).toBe(200); - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - // Verify the response used the pre-parsed body - expect(text).toContain('"id":"preparsed-1"'); - expect(text).toContain('"tools"'); - }); + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - it('should handle pre-parsed batch messages', async () => { - parsedBody = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } - ]; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: '' // Empty as we're using pre-parsed + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]; + + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); + + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); + + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': firstEventId + } + }); + + expect(reconnectResponse.status).toBe(200); + + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); + + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); }); - expect(response.status).toBe(200); + it('should store and replay multiple notifications sent while client is disconnected', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(sseResponse.status).toBe(200); + + const reader = sseResponse.body?.getReader(); - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // Send a notification to get an event ID + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Initial notification' }); - expect(text).toContain('"id":"batch-1"'); - expect(text).toContain('"tools"'); + // Read the notification from the SSE stream + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const lastEventId = idMatch![1]; + + // Close the SSE stream to simulate a disconnect + await reader!.cancel(); + + // Send MULTIPLE notifications while the client is disconnected + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 1' }); + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 2' }); + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Missed notification 3' }); + + // Reconnect with the Last-Event-ID to get all missed messages + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': lastEventId + } + }); + + expect(reconnectResponse.status).toBe(200); + + // Read replayed notifications with a timeout + const reconnectReader = reconnectResponse.body?.getReader(); + let allText = ''; + + // Read chunks until we have all 3 notifications or timeout + const readWithTimeout = async () => { + const timeout = setTimeout(() => reconnectReader!.cancel(), 2000); + try { + while (!allText.includes('Missed notification 3')) { + const { value, done } = await reconnectReader!.read(); + if (done) break; + allText += new TextDecoder().decode(value); + } + } finally { + clearTimeout(timeout); + } + }; + await readWithTimeout(); + + // Verify we received ALL notifications that were sent while disconnected + expect(allText).toContain('Missed notification 1'); + expect(allText).toContain('Missed notification 2'); + expect(allText).toContain('Missed notification 3'); + }); }); - it('should prefer pre-parsed body over request body', async () => { - // Set pre-parsed to tools/list - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-wins' - }; + // Test stateless mode + describe('StreamableHTTPServerTransport in stateless mode', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; - // Send actual body with tools/call - should be ignored - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Ignored' } }, - id: 'ignored-id' - }) + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; }); - expect(response.status).toBe(200); + afterEach(async () => { + await stopTestServer({ server, transport }); + }); - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + it('should operate without session ID validation', async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Should have processed the pre-parsed body - expect(text).toContain('"id":"preparsed-wins"'); - expect(text).toContain('"tools"'); - expect(text).not.toContain('"ignored-id"'); - }); -}); + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get('mcp-session-id')).toBeNull(); -// Test resumability support -describe('StreamableHTTPServerTransport with resumability', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let mcpServer: McpServer; - const storedEvents: Map = new Map(); - - // Simple implementation of EventStore - const eventStore: EventStore = { - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = `${streamId}_${randomUUID()}`; - storedEvents.set(eventId, { eventId, message }); - return eventId; - }, + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - async replayEventsAfter( - lastEventId: EventId, - { - send - }: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - } - ): Promise { - const streamId = lastEventId.split('_')[0]; - // Extract stream ID from the event ID - // For test simplicity, just return all events with matching streamId that aren't the lastEventId - for (const [eventId, { message }] of storedEvents.entries()) { - if (eventId.startsWith(streamId) && eventId !== lastEventId) { - await send(eventId, message); + expect(toolsResponse.status).toBe(200); + }); + + it('should handle POST requests with various session IDs in stateless mode', async () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'random-id-1' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'different-id-2' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) + }); + expect(response2.status).toBe(200); + }); + + it('should reject second SSE stream even in stateless mode', async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-03-26' } - } - return streamId; - } - }; + }); + expect(stream1.status).toBe(200); + + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed + }); + }); + + // Test SSE priming events for POST streams + describe('StreamableHTTPServerTransport POST SSE priming events', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + + // Simple eventStore for priming event tests + const createEventStore = (): EventStore => { + const storedEvents = new Map(); + return { + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}::${Date.now()}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message, streamId }); + return eventId; + }, + async getStreamIdForEventId(eventId: string): Promise { + const event = storedEvents.get(eventId); + return event?.streamId; + }, + async replayEventsAfter( + lastEventId: EventId, + { send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise } + ): Promise { + const event = storedEvents.get(lastEventId); + const streamId = event?.streamId || lastEventId.split('::')[0]; + const eventsToReplay: Array<[string, { message: JSONRPCMessage }]> = []; + for (const [eventId, data] of storedEvents.entries()) { + if (data.streamId === streamId && eventId > lastEventId) { + eventsToReplay.push([eventId, data]); + } + } + eventsToReplay.sort(([a], [b]) => a.localeCompare(b)); + for (const [eventId, { message }] of eventsToReplay) { + if (Object.keys(message).length > 0) { + await send(eventId, message); + } + } + return streamId; + } + }; + }; - beforeEach(async () => { - storedEvents.clear(); - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - eventStore + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - mcpServer = result.mcpServer; + it('should send priming event with retry field on POST SSE stream', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 5000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - // Verify resumability is enabled on the transport - expect(transport['_eventStore']).toBeDefined(); + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); - // Initialize the server - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - expect(sessionId).toBeDefined(); - }); + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Test' } } + }; - afterEach(async () => { - await stopTestServer({ server, transport }); - storedEvents.clear(); - }); + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); - it('should store and include event IDs in server SSE messages', async () => { - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(postResponse.status).toBe(200); + expect(postResponse.headers.get('content-type')).toBe('text/event-stream'); - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); + // Read the priming event + const reader = postResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Send a notification that should be stored with an event ID - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification with event ID' } - }; + // Verify priming event has id and retry field + expect(text).toContain('id: '); + expect(text).toContain('retry: 5000'); + expect(text).toContain('data: '); + }); + + it('should send priming event without retry field when retryInterval is not configured', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore() + // No retryInterval + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - // Send the notification via transport - await transport.send(notification); + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); - // Read from the stream and verify we got the notification with an event ID - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Test' } } + }; - // The response should contain an event ID - expect(text).toContain('id: '); - expect(text).toContain('"method":"notifications/message"'); + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); + expect(postResponse.status).toBe(200); - // Verify the event was stored - const eventId = idMatch![1]; - expect(storedEvents.has(eventId)).toBe(true); - const storedEvent = storedEvents.get(eventId); - expect(eventId.startsWith('_GET_stream')).toBe(true); - expect(storedEvent?.message).toMatchObject(notification); - }); + // Read the priming event + const reader = postResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - it('should store and replay MCP server tool notifications', async () => { - // Establish a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId - } - }); - expect(sseResponse.status).toBe(200); // Send a server notification through the MCP server - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - - // Read the notification from the SSE stream - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Verify the notification was sent with an event ID - expect(text).toContain('id: '); - expect(text).toContain('First notification from MCP server'); - - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); - const firstEventId = idMatch![1]; - - // Send a second notification - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); - - // Close the first SSE stream to simulate a disconnect - await reader!.cancel(); - - // Reconnect with the Last-Event-ID to get missed messages - const reconnectResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26', - 'last-event-id': firstEventId - } + // Priming event should have id field but NOT retry field + expect(text).toContain('id: '); + expect(text).toContain('data: '); + expect(text).not.toContain('retry:'); }); - expect(reconnectResponse.status).toBe(200); + it('should close POST SSE stream when closeSseStream is called', async () => { + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore: createEventStore(), + retryInterval: 1000 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - // Read the replayed notification - const reconnectReader = reconnectResponse.body?.getReader(); - const reconnectData = await reconnectReader!.read(); - const reconnectText = new TextDecoder().decode(reconnectData.value); + // Track tool execution state + let toolResolve: () => void; + const toolPromise = new Promise(resolve => { + toolResolve = resolve; + }); - // Verify we received the second notification that was sent after our stored eventId - expect(reconnectText).toContain('Second notification from MCP server'); - expect(reconnectText).toContain('id: '); - }); -}); + // Register a blocking tool + mcpServer.tool('blocking-tool', 'A blocking tool', {}, async () => { + await toolPromise; + return { content: [{ type: 'text', text: 'Done' }] }; + }); -// Test stateless mode -describe('StreamableHTTPServerTransport in stateless mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); + // Initialize to get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + // Send a tool call request + const toolCallRequest: JSONRPCMessage = { + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { name: 'blocking-tool', arguments: {} } + }; - it('should operate without session ID validation', async () => { - // Initialize the server first - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + const postResponse = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + }, + body: JSON.stringify(toolCallRequest) + }); - expect(initResponse.status).toBe(200); - // Should NOT have session ID header in stateless mode - expect(initResponse.headers.get('mcp-session-id')).toBeNull(); + expect(postResponse.status).toBe(200); - // Try request without session ID - should work in stateless mode - const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + const reader = postResponse.body?.getReader(); - expect(toolsResponse.status).toBe(200); - }); + // Read the priming event + await reader!.read(); - it('should handle POST requests with various session IDs in stateless mode', async () => { - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + // Close the SSE stream + transport.closeSSEStream(100); - // Try with a random session ID - should be accepted - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'random-id-1' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) - }); - expect(response1.status).toBe(200); + // Stream should now be closed + const { done } = await reader!.read(); + expect(done).toBe(true); - // Try with another random session ID - should also be accepted - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'different-id-2' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) + // Clean up - resolve the tool promise + toolResolve!(); }); - expect(response2.status).toBe(200); }); - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time - - // Initialize the server first - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - // Open first SSE stream - const stream1 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream1.status).toBe(200); + // Test onsessionclosed callback + describe('StreamableHTTPServerTransport onsessionclosed callback', () => { + it('should call onsessionclosed callback when session is closed via DELETE', async () => { + const mockCallback = vi.fn(); - // Open second SSE stream - should still be rejected, stateless mode still only allows one - const stream2 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed - }); -}); + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); -// Test onsessionclosed callback -describe('StreamableHTTPServerTransport onsessionclosed callback', () => { - it('should call onsessionclosed callback when session is closed via DELETE', async () => { - const mockCallback = jest.fn(); + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + tempServer.close(); }); - expect(deleteResponse.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(tempSessionId); - expect(mockCallback).toHaveBeenCalledTimes(1); + it('should not call onsessionclosed callback when not provided', async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + }); - // Clean up - tempServer.close(); - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - it('should not call onsessionclosed callback when not provided', async () => { - // Create server without onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID() - }); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(deleteResponse.status).toBe(200); - // DELETE the session - should not throw error - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + tempServer.close(); }); - expect(deleteResponse.status).toBe(200); + it('should not call onsessionclosed callback for invalid session DELETE', async () => { + const mockCallback = vi.fn(); - // Clean up - tempServer.close(); - }); + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - it('should not call onsessionclosed callback for invalid session DELETE', async () => { - const mockCallback = jest.fn(); + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Initialize to get a valid session - await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); - // Try to DELETE with invalid session ID - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + tempServer.close(); }); - expect(deleteResponse.status).toBe(404); - expect(mockCallback).not.toHaveBeenCalled(); + it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { + const mockCallback = vi.fn(); - // Clean up - tempServer.close(); - }); + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { - const mockCallback = jest.fn(); + const server1 = result1.server; + const url1 = result1.baseUrl; - // Create first server - const result1 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - const server1 = result1.server; - const url1 = result1.baseUrl; + const server2 = result2.server; + const url2 = result2.baseUrl; - // Create second server - const result2 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get('mcp-session-id'); - const server2 = result2.server; - const url2 = result2.baseUrl; + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get('mcp-session-id'); - // Initialize both servers - const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); - const sessionId1 = initResponse1.headers.get('mcp-session-id'); + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); - const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); - const sessionId2 = initResponse2.headers.get('mcp-session-id'); + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId1 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(sessionId1).toBeDefined(); - expect(sessionId2).toBeDefined(); - expect(sessionId1).not.toBe(sessionId2); + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); - // DELETE first session - const deleteResponse1 = await fetch(url1, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId1 || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId2 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(deleteResponse1.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId1); - expect(mockCallback).toHaveBeenCalledTimes(1); + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); - // DELETE second session - const deleteResponse2 = await fetch(url2, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId2 || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + server1.close(); + server2.close(); }); - - expect(deleteResponse2.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId2); - expect(mockCallback).toHaveBeenCalledTimes(2); - - // Clean up - server1.close(); - server2.close(); }); -}); - -// Test async callbacks for onsessioninitialized and onsessionclosed -describe('StreamableHTTPServerTransport async callbacks', () => { - it('should support async onsessioninitialized callback', async () => { - const initializationOrder: string[] = []; - - // Create server with async onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - initializationOrder.push('async-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - initializationOrder.push('async-end'); - initializationOrder.push(sessionId); - } - }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Test async callbacks for onsessioninitialized and onsessionclosed + describe('StreamableHTTPServerTransport async callbacks', () => { + it('should support async onsessioninitialized callback', async () => { + const initializationOrder: string[] = []; - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + // Create server with async onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + initializationOrder.push('async-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + initializationOrder.push('async-end'); + initializationOrder.push(sessionId); + } + }); - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); + const tempServer = result.server; + const tempUrl = result.baseUrl; - expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - // Clean up - tempServer.close(); - }); + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); - it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { - const capturedSessionId: string[] = []; + expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); - // Create server with sync onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId: string) => { - capturedSessionId.push(sessionId); - } + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { + const capturedSessionId: string[] = []; - expect(capturedSessionId).toEqual([tempSessionId]); - - // Clean up - tempServer.close(); - }); + // Create server with sync onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + capturedSessionId.push(sessionId); + } + }); - it('should support async onsessionclosed callback', async () => { - const closureOrder: string[] = []; - - // Create server with async onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (sessionId: string) => { - closureOrder.push('async-close-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - closureOrder.push('async-close-end'); - closureOrder.push(sessionId); - } - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); + expect(capturedSessionId).toEqual([tempSessionId]); - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + tempServer.close(); }); - expect(deleteResponse.status).toBe(200); - - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); + it('should support async onsessionclosed callback', async () => { + const closureOrder: string[] = []; - expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); - - // Clean up - tempServer.close(); - }); + // Create server with async onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (sessionId: string) => { + closureOrder.push('async-close-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + closureOrder.push('async-close-end'); + closureOrder.push(sessionId); + } + }); - it('should propagate errors from async onsessioninitialized callback', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Create server with async onsessioninitialized callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (_sessionId: string) => { - throw new Error('Async initialization error'); - } - }); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Initialize should fail when callback throws - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - expect(initResponse.status).toBe(400); + expect(deleteResponse.status).toBe(200); - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); - it('should propagate errors from async onsessionclosed callback', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); - // Create server with async onsessionclosed callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (_sessionId: string) => { - throw new Error('Async closure error'); - } + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should propagate errors from async onsessioninitialized callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + // Create server with async onsessioninitialized callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (_sessionId: string) => { + throw new Error('Async initialization error'); + } + }); - // DELETE should fail when callback throws - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - expect(deleteResponse.status).toBe(500); + // Initialize should fail when callback throws + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(400); - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); - it('should handle both async callbacks together', async () => { - const events: string[] = []; + it('should propagate errors from async onsessionclosed callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - // Create server with both async callbacks - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`initialized:${sessionId}`); - }, - onsessionclosed: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`closed:${sessionId}`); - } - }); + // Create server with async onsessionclosed callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (_sessionId: string) => { + throw new Error('Async closure error'); + } + }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Initialize to trigger first callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); + // DELETE should fail when callback throws + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(events).toContain(`initialized:${tempSessionId}`); + expect(deleteResponse.status).toBe(500); - // DELETE to trigger second callback - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); }); - expect(deleteResponse.status).toBe(200); + it('should handle both async callbacks together', async () => { + const events: string[] = []; - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(events).toContain(`closed:${tempSessionId}`); - expect(events).toHaveLength(2); + // Create server with both async callbacks + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`initialized:${sessionId}`); + }, + onsessionclosed: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`closed:${sessionId}`); + } + }); - // Clean up - tempServer.close(); - }); -}); + const tempServer = result.server; + const tempUrl = result.baseUrl; -// Test DNS rebinding protection -describe('StreamableHTTPServerTransport DNS rebinding protection', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; + // Initialize to trigger first callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - afterEach(async () => { - if (server && transport) { - await stopTestServer({ server, transport }); - } - }); + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); - describe('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + expect(events).toContain(`initialized:${tempSessionId}`); - // Note: fetch() automatically sets Host header to match the URL - // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work - const response = await fetch(baseUrl, { - method: 'POST', + // DELETE to trigger second callback + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } }); - expect(response.status).toBe(200); - }); + expect(deleteResponse.status).toBe(200); - it('should reject requests with disallowed host headers', async () => { - // Test DNS rebinding protection by creating a server that only allows example.com - // but we're connecting via localhost, so it should be rejected - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); + expect(events).toContain(`closed:${tempSessionId}`); + expect(events).toHaveLength(2); - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toContain('Invalid Host header:'); + // Clean up + tempServer.close(); }); + }); - it('should reject GET requests with disallowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream' - } - }); + // Test DNS rebinding protection + describe('StreamableHTTPServerTransport DNS rebinding protection', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; - expect(response.status).toBe(403); + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } }); - }); - describe('Origin header validation', () => { - it('should accept requests with allowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000/', 'https://example.com/'], - enableDnsRebindingProtection: true + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3000/' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + it('should reject requests with disallowed host headers', async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain('Invalid Host header:'); }); - expect(response.status).toBe(200); - }); - - it('should reject requests with disallowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000/'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + it('should reject GET requests with disallowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream' + } + }); - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com/' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + expect(response.status).toBe(403); }); - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); }); - }); - describe('enableDnsRebindingProtection option', () => { - it('should skip all validations when enableDnsRebindingProtection is false', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3000/'], - enableDnsRebindingProtection: false + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000/', 'https://example.com/'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3000/' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Host: 'evil.com', - Origin: 'http://evil.com/' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + it('should reject requests with disallowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000/'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com/' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); }); - - // Should pass even with invalid headers because protection is disabled - expect(response.status).toBe(200); }); - }); - - describe('Combined validations', () => { - it('should validate both host and origin when both are configured', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3001/'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - // Test with invalid origin (host will be automatically correct via fetch) - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com/' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3000/'], + enableDnsRebindingProtection: false + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Host: 'evil.com', + Origin: 'http://evil.com/' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); }); + }); - expect(response1.status).toBe(403); - const body1 = await response1.json(); - expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); - - // Test with valid origin - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3001/' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001/'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com/' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3001/' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response2.status).toBe(200); }); - - expect(response2.status).toBe(200); }); }); }); diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index d57e75cd7..4514e619c 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -35,6 +35,16 @@ export interface EventStore { */ 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, { @@ -108,6 +118,13 @@ export interface StreamableHTTPServerTransportOptions { * Default is false for backwards compatibility. */ 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; } /** @@ -160,6 +177,7 @@ export class StreamableHTTPServerTransport implements Transport { private _allowedHosts?: string[]; private _allowedOrigins?: string[]; private _enableDnsRebindingProtection: boolean; + private _retryInterval?: number; sessionId?: string; onclose?: () => void; @@ -175,6 +193,7 @@ export class StreamableHTTPServerTransport implements Transport { this._allowedHosts = options.allowedHosts; this._allowedOrigins = options.allowedOrigins; this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false; + this._retryInterval = options.retryInterval; } /** @@ -249,6 +268,24 @@ export class StreamableHTTPServerTransport implements Transport { } } + /** + * Writes a priming event to establish resumption capability. + * Only sends if eventStore is configured (opt-in for resumability). + */ + private async _maybeWritePrimingEvent(res: ServerResponse, streamId: string): Promise { + if (!this._eventStore) { + 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 */ @@ -342,6 +379,41 @@ export class StreamableHTTPServerTransport implements Transport { 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', @@ -353,7 +425,8 @@ export class StreamableHTTPServerTransport implements Transport { } res.writeHead(200, headers).flushHeaders(); - const streamId = await this._eventStore?.replayEventsAfter(lastEventId, { + // 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')); @@ -361,7 +434,13 @@ export class StreamableHTTPServerTransport implements Transport { } } }); - this._streamMapping.set(streamId, res); + + 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 => { @@ -547,6 +626,8 @@ export class StreamableHTTPServerTransport implements Transport { } res.writeHead(200, headers); + + await this._maybeWritePrimingEvent(res, streamId); } // Store the response for this request to send messages back through this connection // We need to track by request ID to maintain the connection @@ -709,6 +790,22 @@ export class StreamableHTTPServerTransport implements Transport { 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); + } + } + async send(message: JSONRPCMessage, options?: { relatedRequestId?: RequestId }): Promise { let requestId = options?.relatedRequestId; if (isJSONRPCResponse(message) || isJSONRPCError(message)) { diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 7f0feedc8..9eb99b992 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -1,224 +1,228 @@ import { Server } from './index.js'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; -import { z } from 'zod'; import { McpServer, ResourceTemplate } from './mcp.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; -describe('Title field backwards compatibility', () => { - it('should work with tools that have title', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - - // Register tool with title - server.registerTool( - 'test-tool', - { - title: 'Test Tool Display Name', - description: 'A test tool', - inputSchema: { - value: z.string() - } - }, - async () => ({ content: [{ type: 'text', text: 'result' }] }) - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }); - - await server.server.connect(serverTransport); - await client.connect(clientTransport); - - const tools = await client.listTools(); - expect(tools.tools).toHaveLength(1); - expect(tools.tools[0].name).toBe('test-tool'); - expect(tools.tools[0].title).toBe('Test Tool Display Name'); - expect(tools.tools[0].description).toBe('A test tool'); - }); +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; - it('should work with tools without title', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + describe('Title field backwards compatibility', () => { + it('should work with tools that have title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - // Register tool without title - server.tool('test-tool', 'A test tool', { value: z.string() }, async () => ({ content: [{ type: 'text', text: 'result' }] })); + // Register tool with title + server.registerTool( + 'test-tool', + { + title: 'Test Tool Display Name', + description: 'A test tool', + inputSchema: { + value: z.string() + } + }, + async () => ({ content: [{ type: 'text', text: 'result' }] }) + ); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + await server.server.connect(serverTransport); + await client.connect(clientTransport); - const tools = await client.listTools(); - expect(tools.tools).toHaveLength(1); - expect(tools.tools[0].name).toBe('test-tool'); - expect(tools.tools[0].title).toBeUndefined(); - expect(tools.tools[0].description).toBe('A test tool'); - }); + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe('test-tool'); + expect(tools.tools[0].title).toBe('Test Tool Display Name'); + expect(tools.tools[0].description).toBe('A test tool'); + }); - it('should work with prompts that have title using update', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + it('should work with tools without title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - // Register prompt with title by updating after creation - const prompt = server.prompt('test-prompt', 'A test prompt', async () => ({ - messages: [{ role: 'user', content: { type: 'text', text: 'test' } }] - })); - prompt.update({ title: 'Test Prompt Display Name' }); + // Register tool without title + server.tool('test-tool', 'A test tool', { value: z.string() }, async () => ({ content: [{ type: 'text', text: 'result' }] })); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + await server.server.connect(serverTransport); + await client.connect(clientTransport); - const prompts = await client.listPrompts(); - expect(prompts.prompts).toHaveLength(1); - expect(prompts.prompts[0].name).toBe('test-prompt'); - expect(prompts.prompts[0].title).toBe('Test Prompt Display Name'); - expect(prompts.prompts[0].description).toBe('A test prompt'); - }); + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe('test-tool'); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe('A test tool'); + }); - it('should work with prompts using registerPrompt', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - - // Register prompt with title using registerPrompt - server.registerPrompt( - 'test-prompt', - { - title: 'Test Prompt Display Name', - description: 'A test prompt', - argsSchema: { input: z.string() } - }, - async ({ input }) => ({ - messages: [ - { - role: 'user', - content: { type: 'text', text: `test: ${input}` } - } - ] - }) - ); + it('should work with prompts that have title using update', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + // Register prompt with title by updating after creation + const prompt = server.prompt('test-prompt', 'A test prompt', async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'test' } }] + })); + prompt.update({ title: 'Test Prompt Display Name' }); - 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); - }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - it('should work with resources using registerResource', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - - // Register resource with title using registerResource - server.registerResource( - 'test-resource', - 'https://example.com/test', - { - title: 'Test Resource Display Name', - description: 'A test resource', - mimeType: 'text/plain' - }, - async () => ({ - contents: [ - { - uri: 'https://example.com/test', - text: 'test content' - } - ] - }) - ); + await server.server.connect(serverTransport); + await client.connect(clientTransport); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + 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'); + }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + it('should work with prompts using registerPrompt', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - 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'); - }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - it('should work with dynamic resources using registerResource', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - - // Register dynamic resource with title using registerResource - server.registerResource( - 'user-profile', - new ResourceTemplate('users://{userId}/profile', { list: undefined }), - { - title: 'User Profile', - description: 'User profile information' - }, - async (uri, { userId }, _extra) => ({ - contents: [ + // Register prompt with title using registerPrompt + server.registerPrompt( + 'test-prompt', + { + title: 'Test Prompt Display Name', + description: 'A test prompt', + argsSchema: { input: z.string() } + }, + async ({ input }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `test: ${input}` } + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe('test-prompt'); + expect(prompts.prompts[0].title).toBe('Test Prompt Display Name'); + expect(prompts.prompts[0].description).toBe('A test prompt'); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it('should work with resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register resource with title using registerResource + server.registerResource( + 'test-resource', + 'https://example.com/test', + { + title: 'Test Resource Display Name', + description: 'A test resource', + mimeType: 'text/plain' + }, + async () => ({ + contents: [ + { + uri: 'https://example.com/test', + text: 'test content' + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe('test-resource'); + expect(resources.resources[0].title).toBe('Test Resource Display Name'); + expect(resources.resources[0].description).toBe('A test resource'); + expect(resources.resources[0].mimeType).toBe('text/plain'); + }); + + it('should work with dynamic resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register dynamic resource with title using registerResource + server.registerResource( + 'user-profile', + new ResourceTemplate('users://{userId}/profile', { list: undefined }), + { + title: 'User Profile', + description: 'User profile information' + }, + async (uri, { userId }, _extra) => ({ + contents: [ + { + uri: uri.href, + text: `Profile data for user ${userId}` + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe('user-profile'); + expect(resourceTemplates.resourceTemplates[0].title).toBe('User Profile'); + expect(resourceTemplates.resourceTemplates[0].description).toBe('User profile information'); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe('users://{userId}/profile'); + + // Test reading the resource + const readResult = await client.readResource({ uri: 'users://123/profile' }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents).toEqual( + expect.arrayContaining([ { - uri: uri.href, - text: `Profile data for user ${userId}` + text: expect.stringContaining('Profile data for user 123'), + uri: 'users://123/profile' } - ] - }) - ); - - const client = new Client({ name: 'test-client', version: '1.0.0' }); - - await server.server.connect(serverTransport); - await client.connect(clientTransport); - - const resourceTemplates = await client.listResourceTemplates(); - expect(resourceTemplates.resourceTemplates).toHaveLength(1); - expect(resourceTemplates.resourceTemplates[0].name).toBe('user-profile'); - expect(resourceTemplates.resourceTemplates[0].title).toBe('User Profile'); - expect(resourceTemplates.resourceTemplates[0].description).toBe('User profile information'); - expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe('users://{userId}/profile'); - - // Test reading the resource - const readResult = await client.readResource({ uri: 'users://123/profile' }); - expect(readResult.contents).toHaveLength(1); - expect(readResult.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Profile data for user 123'), - uri: 'users://123/profile' - } - ]) - ); - }); - - it('should support serverInfo with title', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const server = new Server( - { - name: 'test-server', - version: '1.0.0', - title: 'Test Server Display Name' - }, - { capabilities: {} } - ); + ]) + ); + }); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + it('should support serverInfo with title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const serverInfo = client.getServerVersion(); - expect(serverInfo?.name).toBe('test-server'); - expect(serverInfo?.version).toBe('1.0.0'); - expect(serverInfo?.title).toBe('Test Server Display Name'); + const server = new Server( + { + name: 'test-server', + version: '1.0.0', + title: 'Test Server Display Name' + }, + { capabilities: {} } + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('test-server'); + expect(serverInfo?.version).toBe('1.0.0'); + expect(serverInfo?.title).toBe('Test Server Display Name'); + }); }); }); diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts new file mode 100644 index 000000000..956aca821 --- /dev/null +++ b/src/server/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?: { + typeName?: 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.typeName === '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?.typeName === 'ZodOptional'; + } + 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 new file mode 100644 index 000000000..cde66b177 --- /dev/null +++ b/src/server/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/src/shared/auth.test.ts b/src/shared/auth.test.ts index 71877f341..3a3b00eb2 100644 --- a/src/shared/auth.test.ts +++ b/src/shared/auth.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from '@jest/globals'; import { SafeUrlSchema, OAuthMetadataSchema, diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 819b33086..b37a4c70c 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,10 +1,9 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; /** * Reusable URL validation that disallows javascript: scheme */ export const SafeUrlSchema = z - .string() .url() .superRefine((val, ctx) => { if (!URL.canParse(val)) { @@ -28,105 +27,102 @@ export const SafeUrlSchema = z /** * RFC 9728 OAuth Protected Resource Metadata */ -export const OAuthProtectedResourceMetadataSchema = z - .object({ - 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() - }) - .passthrough(); +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 - .object({ - 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() - }) - .passthrough(); +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 - .object({ - 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() - }) - .passthrough(); +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 = OpenIdProviderMetadataSchema.merge( - OAuthMetadataSchema.pick({ +export const OpenIdProviderDiscoveryMetadataSchema = z.object({ + ...OpenIdProviderMetadataSchema.shape, + ...OAuthMetadataSchema.pick({ code_challenge_methods_supported: true - }) -); + }).shape +}); /** * OAuth 2.1 token response diff --git a/src/shared/protocol-transport-handling.test.ts b/src/shared/protocol-transport-handling.test.ts index 375a0ee78..b463d6db4 100644 --- a/src/shared/protocol-transport-handling.test.ts +++ b/src/shared/protocol-transport-handling.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test, beforeEach } from '@jest/globals'; +import { describe, expect, test, beforeEach } from 'vitest'; import { Protocol } from './protocol.js'; import { Transport } from './transport.js'; import { Request, Notification, Result, JSONRPCMessage } from '../types.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; // Mock Transport class class MockTransport implements Transport { diff --git a/src/shared/protocol.test.ts b/src/shared/protocol.test.ts index 802c1dd9d..b47de8c55 100644 --- a/src/shared/protocol.test.ts +++ b/src/shared/protocol.test.ts @@ -2,6 +2,7 @@ import { ZodType, z } from 'zod'; import { ClientCapabilities, ErrorCode, McpError, Notification, Request, Result, ServerCapabilities } from '../types.js'; import { Protocol, mergeCapabilities } from './protocol.js'; import { Transport } from './transport.js'; +import { MockInstance } from 'vitest'; // Mock Transport class class MockTransport implements Transport { @@ -19,11 +20,11 @@ class MockTransport implements Transport { describe('protocol tests', () => { let protocol: Protocol; let transport: MockTransport; - let sendSpy: jest.SpyInstance; + let sendSpy: MockInstance; beforeEach(() => { transport = new MockTransport(); - sendSpy = jest.spyOn(transport, 'send'); + sendSpy = vi.spyOn(transport, 'send'); protocol = new (class extends Protocol { protected assertCapabilityForMethod(): void {} protected assertNotificationCapability(): void {} @@ -50,7 +51,7 @@ describe('protocol tests', () => { }); test('should invoke onclose when the connection is closed', async () => { - const oncloseMock = jest.fn(); + const oncloseMock = vi.fn(); protocol.onclose = oncloseMock; await protocol.connect(transport); await transport.close(); @@ -58,9 +59,9 @@ describe('protocol tests', () => { }); test('should not overwrite existing hooks when connecting transports', async () => { - const oncloseMock = jest.fn(); - const onerrorMock = jest.fn(); - const onmessageMock = jest.fn(); + const oncloseMock = vi.fn(); + const onerrorMock = vi.fn(); + const onmessageMock = vi.fn(); transport.onclose = oncloseMock; transport.onerror = onerrorMock; transport.onmessage = onmessageMock; @@ -89,7 +90,7 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); protocol.request(request, mockSchema, { onprogress: onProgressMock @@ -124,7 +125,7 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); protocol.request(request, mockSchema, { onprogress: onProgressMock @@ -187,7 +188,7 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); protocol.request(request, mockSchema, { onprogress: onProgressMock @@ -211,10 +212,10 @@ describe('protocol tests', () => { describe('progress notification timeout behavior', () => { beforeEach(() => { - jest.useFakeTimers(); + vi.useFakeTimers(); }); afterEach(() => { - jest.useRealTimers(); + vi.useRealTimers(); }); test('should not reset timeout when resetTimeoutOnProgress is false', async () => { @@ -223,14 +224,14 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); const requestPromise = protocol.request(request, mockSchema, { timeout: 1000, resetTimeoutOnProgress: false, onprogress: onProgressMock }); - jest.advanceTimersByTime(800); + vi.advanceTimersByTime(800); if (transport.onmessage) { transport.onmessage({ @@ -250,7 +251,7 @@ describe('protocol tests', () => { total: 100 }); - jest.advanceTimersByTime(201); + vi.advanceTimersByTime(201); await expect(requestPromise).rejects.toThrow('Request timed out'); }); @@ -261,13 +262,13 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); const requestPromise = protocol.request(request, mockSchema, { timeout: 1000, resetTimeoutOnProgress: true, onprogress: onProgressMock }); - jest.advanceTimersByTime(800); + vi.advanceTimersByTime(800); if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', @@ -284,7 +285,7 @@ describe('protocol tests', () => { progress: 50, total: 100 }); - jest.advanceTimersByTime(800); + vi.advanceTimersByTime(800); if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', @@ -302,7 +303,7 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); const requestPromise = protocol.request(request, mockSchema, { timeout: 1000, maxTotalTimeout: 150, @@ -311,7 +312,7 @@ describe('protocol tests', () => { }); // First progress notification should work - jest.advanceTimersByTime(80); + vi.advanceTimersByTime(80); if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', @@ -328,7 +329,7 @@ describe('protocol tests', () => { progress: 50, total: 100 }); - jest.advanceTimersByTime(80); + vi.advanceTimersByTime(80); if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', @@ -354,7 +355,7 @@ describe('protocol tests', () => { timeout: 100, resetTimeoutOnProgress: true }); - jest.advanceTimersByTime(101); + vi.advanceTimersByTime(101); await expect(requestPromise).rejects.toThrow('Request timed out'); }); @@ -364,7 +365,7 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); const requestPromise = protocol.request(request, mockSchema, { timeout: 1000, resetTimeoutOnProgress: true, @@ -373,7 +374,7 @@ describe('protocol tests', () => { // Simulate multiple progress updates for (let i = 1; i <= 3; i++) { - jest.advanceTimersByTime(800); + vi.advanceTimersByTime(800); if (transport.onmessage) { transport.onmessage({ jsonrpc: '2.0', @@ -408,14 +409,14 @@ describe('protocol tests', () => { const mockSchema: ZodType<{ result: string }> = z.object({ result: z.string() }); - const onProgressMock = jest.fn(); + const onProgressMock = vi.fn(); const requestPromise = protocol.request(request, mockSchema, { timeout: 1000, onprogress: onProgressMock }); - jest.advanceTimersByTime(200); + vi.advanceTimersByTime(200); if (transport.onmessage) { transport.onmessage({ @@ -437,7 +438,7 @@ describe('protocol tests', () => { message: 'Initializing process...' }); - jest.advanceTimersByTime(200); + vi.advanceTimersByTime(200); if (transport.onmessage) { transport.onmessage({ diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 48cad896f..add69163c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,4 +1,4 @@ -import { ZodLiteral, ZodObject, ZodType, z } from 'zod'; +import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js'; import { CancelledNotificationSchema, ClientCapabilities, @@ -27,6 +27,7 @@ import { } from '../types.js'; import { Transport, TransportSendOptions } from './transport.js'; import { AuthInfo } from '../server/auth/types.js'; +import { getMethodLiteral, parseWithCompat } from '../server/zod-json-schema-compat.js'; /** * Callback for progress notifications. @@ -152,7 +153,7 @@ export type RequestHandlerExtra>(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; + sendRequest: (request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; }; /** @@ -250,7 +251,7 @@ export abstract class Protocol= info.maxTotalTimeout) { this._timeoutInfo.delete(messageId); - throw new McpError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { + throw McpError.fromError(ErrorCode.RequestTimeout, 'Maximum total timeout exceeded', { maxTotalTimeout: info.maxTotalTimeout, totalElapsed }); @@ -313,7 +314,7 @@ export abstract class Protocol>(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise> { + request(request: SendRequestT, resultSchema: T, options?: RequestOptions): Promise> { const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {}; return new Promise((resolve, reject) => { @@ -554,8 +556,13 @@ export abstract class Protocol); + } } catch (error) { reject(error); } @@ -566,7 +573,7 @@ export abstract class Protocol cancel(new McpError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); + const timeoutHandler = () => cancel(McpError.fromError(ErrorCode.RequestTimeout, 'Request timed out', { timeout })); this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false); @@ -638,19 +645,19 @@ export abstract class Protocol; - }> - >( + setRequestHandler( requestSchema: T, - handler: (request: z.infer, extra: RequestHandlerExtra) => SendResultT | Promise + handler: ( + request: SchemaOutput, + extra: RequestHandlerExtra + ) => SendResultT | Promise ): void { - const method = requestSchema.shape.method.value; + const method = getMethodLiteral(requestSchema); this.assertRequestHandlerCapability(method); this._requestHandlers.set(method, (request, extra) => { - return Promise.resolve(handler(requestSchema.parse(request), extra)); + const parsed = parseWithCompat(requestSchema, request) as SchemaOutput; + return Promise.resolve(handler(parsed, extra)); }); } @@ -675,14 +682,15 @@ export abstract class Protocol; - }> - >(notificationSchema: T, handler: (notification: z.infer) => void | Promise): void { - this._notificationHandlers.set(notificationSchema.shape.method.value, notification => - Promise.resolve(handler(notificationSchema.parse(notification))) - ); + 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)); + }); } /** diff --git a/src/shared/toolNameValidation.test.ts b/src/shared/toolNameValidation.test.ts index 64ba9d3ba..e816f9b4b 100644 --- a/src/shared/toolNameValidation.test.ts +++ b/src/shared/toolNameValidation.test.ts @@ -1,14 +1,15 @@ import { validateToolName, validateAndWarnToolName, issueToolNameWarning } from './toolNameValidation.js'; +import { vi, MockInstance } from 'vitest'; // Spy on console.warn to capture output -let warnSpy: jest.SpyInstance; +let warnSpy: MockInstance; beforeEach(() => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { - jest.restoreAllMocks(); + vi.restoreAllMocks(); }); describe('validateToolName', () => { diff --git a/src/shared/transport.ts b/src/shared/transport.ts index 8f0c291d2..7e15dca47 100644 --- a/src/shared/transport.ts +++ b/src/shared/transport.ts @@ -2,6 +2,49 @@ 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. */ diff --git a/src/shared/zodTestMatrix.ts b/src/shared/zodTestMatrix.ts new file mode 100644 index 000000000..fc4ee63db --- /dev/null +++ b/src/shared/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/src/spec.types.test.ts b/src/spec.types.test.ts index 2417e6b1d..1c0b6ab5d 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -76,88 +76,112 @@ type FixSpecInitializeRequest = T extends { params: infer P } ? Omit = T extends { params: infer P } ? Omit & { params: FixSpecInitializeRequestParams

} : T; const sdkTypeChecks = { - RequestParams: (sdk: SDKTypes.RequestParams, spec: SpecTypes.RequestParams) => { + RequestParams: (sdk: RemovePassthrough, spec: SpecTypes.RequestParams) => { sdk = spec; spec = sdk; }, - NotificationParams: (sdk: SDKTypes.NotificationParams, spec: SpecTypes.NotificationParams) => { + NotificationParams: (sdk: RemovePassthrough, spec: SpecTypes.NotificationParams) => { sdk = spec; spec = sdk; }, - CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { + CancelledNotificationParams: ( + sdk: RemovePassthrough, + spec: SpecTypes.CancelledNotificationParams + ) => { sdk = spec; spec = sdk; }, InitializeRequestParams: ( - sdk: SDKTypes.InitializeRequestParams, + sdk: RemovePassthrough, spec: FixSpecInitializeRequestParams ) => { sdk = spec; spec = sdk; }, - ProgressNotificationParams: (sdk: SDKTypes.ProgressNotificationParams, spec: SpecTypes.ProgressNotificationParams) => { + ProgressNotificationParams: ( + sdk: RemovePassthrough, + spec: SpecTypes.ProgressNotificationParams + ) => { sdk = spec; spec = sdk; }, - ResourceRequestParams: (sdk: SDKTypes.ResourceRequestParams, spec: SpecTypes.ResourceRequestParams) => { + ResourceRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.ResourceRequestParams) => { sdk = spec; spec = sdk; }, - ReadResourceRequestParams: (sdk: SDKTypes.ReadResourceRequestParams, spec: SpecTypes.ReadResourceRequestParams) => { + ReadResourceRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.ReadResourceRequestParams) => { sdk = spec; spec = sdk; }, - SubscribeRequestParams: (sdk: SDKTypes.SubscribeRequestParams, spec: SpecTypes.SubscribeRequestParams) => { + SubscribeRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.SubscribeRequestParams) => { sdk = spec; spec = sdk; }, - UnsubscribeRequestParams: (sdk: SDKTypes.UnsubscribeRequestParams, spec: SpecTypes.UnsubscribeRequestParams) => { + UnsubscribeRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.UnsubscribeRequestParams) => { sdk = spec; spec = sdk; }, ResourceUpdatedNotificationParams: ( - sdk: SDKTypes.ResourceUpdatedNotificationParams, + sdk: RemovePassthrough, spec: SpecTypes.ResourceUpdatedNotificationParams ) => { sdk = spec; spec = sdk; }, - GetPromptRequestParams: (sdk: SDKTypes.GetPromptRequestParams, spec: SpecTypes.GetPromptRequestParams) => { + GetPromptRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.GetPromptRequestParams) => { sdk = spec; spec = sdk; }, - CallToolRequestParams: (sdk: SDKTypes.CallToolRequestParams, spec: SpecTypes.CallToolRequestParams) => { + CallToolRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.CallToolRequestParams) => { sdk = spec; spec = sdk; }, - SetLevelRequestParams: (sdk: SDKTypes.SetLevelRequestParams, spec: SpecTypes.SetLevelRequestParams) => { + SetLevelRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.SetLevelRequestParams) => { sdk = spec; spec = sdk; }, LoggingMessageNotificationParams: ( - sdk: MakeUnknownsNotOptional, + sdk: MakeUnknownsNotOptional>, spec: SpecTypes.LoggingMessageNotificationParams ) => { sdk = spec; spec = sdk; }, - CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + CreateMessageRequestParams: ( + sdk: RemovePassthrough, + spec: SpecTypes.CreateMessageRequestParams + ) => { + sdk = spec; + spec = sdk; + }, + CompleteRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.CompleteRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.ElicitRequestParams) => { sdk = spec; spec = sdk; }, - CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + ElicitRequestFormParams: (sdk: RemovePassthrough, spec: SpecTypes.ElicitRequestFormParams) => { sdk = spec; spec = sdk; }, - ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + ElicitRequestURLParams: (sdk: RemovePassthrough, spec: SpecTypes.ElicitRequestURLParams) => { sdk = spec; spec = sdk; }, - PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + ElicitationCompleteNotification: ( + sdk: RemovePassthrough>, + spec: SpecTypes.ElicitationCompleteNotification + ) => { sdk = spec; spec = sdk; }, - CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + PaginatedRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.PaginatedRequestParams) => { + sdk = spec; + spec = sdk; + }, + CancelledNotification: (sdk: RemovePassthrough>, spec: SpecTypes.CancelledNotification) => { sdk = spec; spec = sdk; }, @@ -201,7 +225,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - ElicitRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ElicitRequest) => { + ElicitRequest: (sdk: RemovePassthrough>, spec: SpecTypes.ElicitRequest) => { sdk = spec; spec = sdk; }, @@ -209,7 +233,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + CompleteRequest: (sdk: RemovePassthrough>, spec: SpecTypes.CompleteRequest) => { sdk = spec; spec = sdk; }, @@ -336,11 +360,11 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - SamplingMessage: (sdk: SDKTypes.SamplingMessage, spec: SpecTypes.SamplingMessage) => { + SamplingMessage: (sdk: RemovePassthrough, spec: SpecTypes.SamplingMessage) => { sdk = spec; spec = sdk; }, - CreateMessageResult: (sdk: SDKTypes.CreateMessageResult, spec: SpecTypes.CreateMessageResult) => { + CreateMessageResult: (sdk: RemovePassthrough, spec: SpecTypes.CreateMessageResult) => { sdk = spec; spec = sdk; }, @@ -520,12 +544,15 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { + CreateMessageRequest: ( + sdk: RemovePassthrough>, + spec: SpecTypes.CreateMessageRequest + ) => { sdk = spec; spec = sdk; }, InitializeRequest: ( - sdk: WithJSONRPCRequest, + sdk: RemovePassthrough>, spec: FixSpecInitializeRequest ) => { sdk = spec; @@ -587,6 +614,25 @@ const sdkTypeChecks = { ModelPreferences: (sdk: SDKTypes.ModelPreferences, spec: SpecTypes.ModelPreferences) => { sdk = spec; spec = sdk; + }, + ToolChoice: (sdk: SDKTypes.ToolChoice, spec: SpecTypes.ToolChoice) => { + sdk = spec; + spec = sdk; + }, + ToolUseContent: (sdk: RemovePassthrough, spec: SpecTypes.ToolUseContent) => { + sdk = spec; + spec = sdk; + }, + ToolResultContent: (sdk: RemovePassthrough, spec: SpecTypes.ToolResultContent) => { + sdk = spec; + spec = sdk; + }, + SamplingMessageContentBlock: ( + sdk: RemovePassthrough, + spec: SpecTypes.SamplingMessageContentBlock + ) => { + sdk = spec; + spec = sdk; } }; @@ -598,6 +644,7 @@ const MISSING_SDK_TYPES = [ // These are inlined in the SDK: 'Role', 'Error', // The inner error object of a JSONRPCError + 'URLElicitationRequiredError', // In the SDK, but with a custom definition // These aren't supported by the SDK yet: // TODO: Add definitions to the SDK 'Annotations' @@ -615,7 +662,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(119); + expect(specTypes).toHaveLength(127); }); it('should have up to date list of missing sdk types', () => { diff --git a/src/spec.types.ts b/src/spec.types.ts index c58636350..49f2457ce 100644 --- a/src/spec.types.ts +++ b/src/spec.types.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 11ad2a720d8e2f54881235f734121db0bda39052 + * Last updated from commit: 7dcdd69262bd488ddec071bf4eefedabf1742023 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: npm run fetch:spec-types @@ -12,7 +12,7 @@ /** * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. * - * @internal + * @category JSON-RPC */ export type JSONRPCMessage = | JSONRPCRequest @@ -27,16 +27,22 @@ 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 request. + * + * @internal */ export interface RequestParams { /** @@ -75,6 +81,9 @@ export interface Notification { params?: { [key: string]: any }; } +/** + * @category Common Types + */ export interface Result { /** * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. @@ -83,6 +92,9 @@ export interface Result { [key: string]: unknown; } +/** + * @category Common Types + */ export interface Error { /** * The error type that occurred. @@ -100,11 +112,15 @@ export interface Error { /** * 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; @@ -113,6 +129,8 @@ export interface JSONRPCRequest extends Request { /** * A notification which does not expect a response. + * + * @category JSON-RPC */ export interface JSONRPCNotification extends Notification { jsonrpc: typeof JSONRPC_VERSION; @@ -120,6 +138,8 @@ export interface JSONRPCNotification extends Notification { /** * A successful (non-error) response to a request. + * + * @category JSON-RPC */ export interface JSONRPCResponse { jsonrpc: typeof JSONRPC_VERSION; @@ -128,19 +148,20 @@ export interface JSONRPCResponse { } // Standard JSON-RPC error codes -/** @internal */ export const PARSE_ERROR = -32700; -/** @internal */ export const INVALID_REQUEST = -32600; -/** @internal */ export const METHOD_NOT_FOUND = -32601; -/** @internal */ export const INVALID_PARAMS = -32602; -/** @internal */ export const INTERNAL_ERROR = -32603; +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + /** * A response to a request that indicates an error occurred. + * + * @category JSON-RPC */ export interface JSONRPCError { jsonrpc: typeof JSONRPC_VERSION; @@ -148,9 +169,27 @@ export interface JSONRPCError { error: Error; } +/** + * 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; @@ -158,7 +197,7 @@ export type EmptyResult = Result; /** * Parameters for a `notifications/cancelled` notification. * - * @category notifications/cancelled + * @category `notifications/cancelled` */ export interface CancelledNotificationParams extends NotificationParams { /** @@ -183,7 +222,7 @@ export interface CancelledNotificationParams extends NotificationParams { * * A client MUST NOT attempt to cancel its `initialize` request. * - * @category notifications/cancelled + * @category `notifications/cancelled` */ export interface CancelledNotification extends JSONRPCNotification { method: "notifications/cancelled"; @@ -194,7 +233,7 @@ export interface CancelledNotification extends JSONRPCNotification { /** * Parameters for an `initialize` request. * - * @category initialize + * @category `initialize` */ export interface InitializeRequestParams extends RequestParams { /** @@ -208,7 +247,7 @@ export interface InitializeRequestParams extends RequestParams { /** * This request is sent from the client to the server when it first connects, asking it to begin initialization. * - * @category initialize + * @category `initialize` */ export interface InitializeRequest extends JSONRPCRequest { method: "initialize"; @@ -218,7 +257,7 @@ export interface InitializeRequest extends JSONRPCRequest { /** * After receiving an initialize request from the client, the server sends this response. * - * @category initialize + * @category `initialize` */ export interface InitializeResult extends Result { /** @@ -239,7 +278,7 @@ export interface InitializeResult extends Result { /** * This notification is sent from the client to the server after initialization has finished. * - * @category notifications/initialized + * @category `notifications/initialized` */ export interface InitializedNotification extends JSONRPCNotification { method: "notifications/initialized"; @@ -248,6 +287,8 @@ export interface InitializedNotification extends JSONRPCNotification { /** * 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 { /** @@ -266,15 +307,27 @@ export interface ClientCapabilities { /** * Present if the client supports sampling from an LLM. */ - sampling?: object; + 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?: object; + elicitation?: { form?: object; url?: 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 { /** @@ -324,6 +377,8 @@ export interface ServerCapabilities { /** * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types */ export interface Icon { /** @@ -407,11 +462,22 @@ export interface BaseMetadata { } /** - * Describes the MCP implementation + * 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. * @@ -424,7 +490,7 @@ export interface Implementation extends BaseMetadata, Icons { /** * 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 + * @category `ping` */ export interface PingRequest extends JSONRPCRequest { method: "ping"; @@ -436,7 +502,7 @@ export interface PingRequest extends JSONRPCRequest { /** * Parameters for a `notifications/progress` notification. * - * @category notifications/progress + * @category `notifications/progress` */ export interface ProgressNotificationParams extends NotificationParams { /** @@ -464,7 +530,7 @@ export interface ProgressNotificationParams extends NotificationParams { /** * An out-of-band notification used to inform the receiver of a progress update for a long-running request. * - * @category notifications/progress + * @category `notifications/progress` */ export interface ProgressNotification extends JSONRPCNotification { method: "notifications/progress"; @@ -474,6 +540,8 @@ export interface ProgressNotification extends JSONRPCNotification { /* Pagination */ /** * Common parameters for paginated requests. + * + * @internal */ export interface PaginatedRequestParams extends RequestParams { /** @@ -501,7 +569,7 @@ export interface PaginatedResult extends Result { /** * Sent from the client to request a list of resources the server has. * - * @category resources/list + * @category `resources/list` */ export interface ListResourcesRequest extends PaginatedRequest { method: "resources/list"; @@ -510,7 +578,7 @@ export interface ListResourcesRequest extends PaginatedRequest { /** * The server's response to a resources/list request from the client. * - * @category resources/list + * @category `resources/list` */ export interface ListResourcesResult extends PaginatedResult { resources: Resource[]; @@ -519,7 +587,7 @@ export interface ListResourcesResult extends PaginatedResult { /** * Sent from the client to request a list of resource templates the server has. * - * @category resources/templates/list + * @category `resources/templates/list` */ export interface ListResourceTemplatesRequest extends PaginatedRequest { method: "resources/templates/list"; @@ -528,7 +596,7 @@ export interface ListResourceTemplatesRequest extends PaginatedRequest { /** * The server's response to a resources/templates/list request from the client. * - * @category resources/templates/list + * @category `resources/templates/list` */ export interface ListResourceTemplatesResult extends PaginatedResult { resourceTemplates: ResourceTemplate[]; @@ -551,15 +619,15 @@ export interface ResourceRequestParams extends RequestParams { /** * Parameters for a `resources/read` request. * - * @category resources/read + * @category `resources/read` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ReadResourceRequestParams extends ResourceRequestParams {} +export interface ReadResourceRequestParams extends ResourceRequestParams { } /** * Sent from the client to the server, to read a specific resource URI. * - * @category resources/read + * @category `resources/read` */ export interface ReadResourceRequest extends JSONRPCRequest { method: "resources/read"; @@ -569,7 +637,7 @@ export interface ReadResourceRequest extends JSONRPCRequest { /** * The server's response to a resources/read request from the client. * - * @category resources/read + * @category `resources/read` */ export interface ReadResourceResult extends Result { contents: (TextResourceContents | BlobResourceContents)[]; @@ -578,7 +646,7 @@ export interface ReadResourceResult extends Result { /** * 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 + * @category `notifications/resources/list_changed` */ export interface ResourceListChangedNotification extends JSONRPCNotification { method: "notifications/resources/list_changed"; @@ -588,15 +656,15 @@ export interface ResourceListChangedNotification extends JSONRPCNotification { /** * Parameters for a `resources/subscribe` request. * - * @category resources/subscribe + * @category `resources/subscribe` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface SubscribeRequestParams extends ResourceRequestParams {} +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 + * @category `resources/subscribe` */ export interface SubscribeRequest extends JSONRPCRequest { method: "resources/subscribe"; @@ -606,15 +674,15 @@ export interface SubscribeRequest extends JSONRPCRequest { /** * Parameters for a `resources/unsubscribe` request. * - * @category resources/unsubscribe + * @category `resources/unsubscribe` */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface UnsubscribeRequestParams extends ResourceRequestParams {} +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 + * @category `resources/unsubscribe` */ export interface UnsubscribeRequest extends JSONRPCRequest { method: "resources/unsubscribe"; @@ -624,7 +692,7 @@ export interface UnsubscribeRequest extends JSONRPCRequest { /** * Parameters for a `notifications/resources/updated` notification. * - * @category notifications/resources/updated + * @category `notifications/resources/updated` */ export interface ResourceUpdatedNotificationParams extends NotificationParams { /** @@ -638,7 +706,7 @@ export interface ResourceUpdatedNotificationParams extends NotificationParams { /** * 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 + * @category `notifications/resources/updated` */ export interface ResourceUpdatedNotification extends JSONRPCNotification { method: "notifications/resources/updated"; @@ -647,6 +715,8 @@ export interface ResourceUpdatedNotification extends JSONRPCNotification { /** * A known resource that the server is capable of reading. + * + * @category `resources/list` */ export interface Resource extends BaseMetadata, Icons { /** @@ -688,6 +758,8 @@ export interface Resource extends BaseMetadata, Icons { /** * A template description for resources available on the server. + * + * @category `resources/templates/list` */ export interface ResourceTemplate extends BaseMetadata, Icons { /** @@ -722,6 +794,8 @@ export interface ResourceTemplate extends BaseMetadata, Icons { /** * The contents of a specific resource or sub-resource. + * + * @internal */ export interface ResourceContents { /** @@ -741,6 +815,9 @@ export interface ResourceContents { _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). @@ -748,6 +825,9 @@ export interface TextResourceContents extends ResourceContents { text: string; } +/** + * @category Content + */ export interface BlobResourceContents extends ResourceContents { /** * A base64-encoded string representing the binary data of the item. @@ -761,7 +841,7 @@ export interface BlobResourceContents extends ResourceContents { /** * Sent from the client to request a list of prompts and prompt templates the server has. * - * @category prompts/list + * @category `prompts/list` */ export interface ListPromptsRequest extends PaginatedRequest { method: "prompts/list"; @@ -770,7 +850,7 @@ export interface ListPromptsRequest extends PaginatedRequest { /** * The server's response to a prompts/list request from the client. * - * @category prompts/list + * @category `prompts/list` */ export interface ListPromptsResult extends PaginatedResult { prompts: Prompt[]; @@ -779,7 +859,7 @@ export interface ListPromptsResult extends PaginatedResult { /** * Parameters for a `prompts/get` request. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptRequestParams extends RequestParams { /** @@ -795,7 +875,7 @@ export interface GetPromptRequestParams extends RequestParams { /** * Used by the client to get a prompt provided by the server. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptRequest extends JSONRPCRequest { method: "prompts/get"; @@ -805,7 +885,7 @@ export interface GetPromptRequest extends JSONRPCRequest { /** * The server's response to a prompts/get request from the client. * - * @category prompts/get + * @category `prompts/get` */ export interface GetPromptResult extends Result { /** @@ -817,6 +897,8 @@ export interface GetPromptResult extends Result { /** * A prompt or prompt template that the server offers. + * + * @category `prompts/list` */ export interface Prompt extends BaseMetadata, Icons { /** @@ -837,6 +919,8 @@ export interface Prompt extends BaseMetadata, Icons { /** * Describes an argument that a prompt can accept. + * + * @category `prompts/list` */ export interface PromptArgument extends BaseMetadata { /** @@ -851,6 +935,8 @@ export interface PromptArgument extends BaseMetadata { /** * The sender or recipient of messages and data in a conversation. + * + * @category Common Types */ export type Role = "user" | "assistant"; @@ -859,6 +945,8 @@ export type Role = "user" | "assistant"; * * This is similar to `SamplingMessage`, but also supports the embedding of * resources from the MCP server. + * + * @category `prompts/get` */ export interface PromptMessage { role: Role; @@ -869,6 +957,8 @@ export interface PromptMessage { * 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"; @@ -879,6 +969,8 @@ export interface ResourceLink extends Resource { * * 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"; @@ -897,7 +989,7 @@ export interface EmbeddedResource { /** * 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 + * @category `notifications/prompts/list_changed` */ export interface PromptListChangedNotification extends JSONRPCNotification { method: "notifications/prompts/list_changed"; @@ -908,7 +1000,7 @@ export interface PromptListChangedNotification extends JSONRPCNotification { /** * Sent from the client to request a list of tools the server has. * - * @category tools/list + * @category `tools/list` */ export interface ListToolsRequest extends PaginatedRequest { method: "tools/list"; @@ -917,7 +1009,7 @@ export interface ListToolsRequest extends PaginatedRequest { /** * The server's response to a tools/list request from the client. * - * @category tools/list + * @category `tools/list` */ export interface ListToolsResult extends PaginatedResult { tools: Tool[]; @@ -926,7 +1018,7 @@ export interface ListToolsResult extends PaginatedResult { /** * The server's response to a tool call. * - * @category tools/call + * @category `tools/call` */ export interface CallToolResult extends Result { /** @@ -959,7 +1051,7 @@ export interface CallToolResult extends Result { /** * Parameters for a `tools/call` request. * - * @category tools/call + * @category `tools/call` */ export interface CallToolRequestParams extends RequestParams { /** @@ -975,7 +1067,7 @@ export interface CallToolRequestParams extends RequestParams { /** * Used by the client to invoke a tool provided by the server. * - * @category tools/call + * @category `tools/call` */ export interface CallToolRequest extends JSONRPCRequest { method: "tools/call"; @@ -985,7 +1077,7 @@ export interface CallToolRequest extends JSONRPCRequest { /** * 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 + * @category `notifications/tools/list_changed` */ export interface ToolListChangedNotification extends JSONRPCNotification { method: "notifications/tools/list_changed"; @@ -1001,6 +1093,8 @@ export interface ToolListChangedNotification extends JSONRPCNotification { * * Clients should never make tool use decisions based on ToolAnnotations * received from untrusted servers. + * + * @category `tools/list` */ export interface ToolAnnotations { /** @@ -1048,6 +1142,8 @@ export interface ToolAnnotations { /** * Definition for a tool the client can call. + * + * @category `tools/list` */ export interface Tool extends BaseMetadata, Icons { /** @@ -1094,7 +1190,7 @@ export interface Tool extends BaseMetadata, Icons { /** * Parameters for a `logging/setLevel` request. * - * @category logging/setLevel + * @category `logging/setLevel` */ export interface SetLevelRequestParams extends RequestParams { /** @@ -1106,7 +1202,7 @@ export interface SetLevelRequestParams extends RequestParams { /** * A request from the client to the server, to enable or adjust logging. * - * @category logging/setLevel + * @category `logging/setLevel` */ export interface SetLevelRequest extends JSONRPCRequest { method: "logging/setLevel"; @@ -1116,7 +1212,7 @@ export interface SetLevelRequest extends JSONRPCRequest { /** * Parameters for a `notifications/message` notification. * - * @category notifications/message + * @category `notifications/message` */ export interface LoggingMessageNotificationParams extends NotificationParams { /** @@ -1136,7 +1232,7 @@ export interface LoggingMessageNotificationParams extends NotificationParams { /** * 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 + * @category `notifications/message` */ export interface LoggingMessageNotification extends JSONRPCNotification { method: "notifications/message"; @@ -1148,6 +1244,8 @@ export interface LoggingMessageNotification extends JSONRPCNotification { * * 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" @@ -1163,7 +1261,7 @@ export type LoggingLevel = /** * Parameters for a `sampling/createMessage` request. * - * @category sampling/createMessage + * @category `sampling/createMessage` */ export interface CreateMessageRequestParams extends RequestParams { messages: SamplingMessage[]; @@ -1176,7 +1274,11 @@ export interface CreateMessageRequestParams extends RequestParams { */ 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. + * 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"; /** @@ -1194,12 +1296,38 @@ export interface CreateMessageRequestParams extends RequestParams { * 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 + * @category `sampling/createMessage` */ export interface CreateMessageRequest extends JSONRPCRequest { method: "sampling/createMessage"; @@ -1209,33 +1337,56 @@ export interface CreateMessageRequest extends JSONRPCRequest { /** * The client's response to a sampling/create_message 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 + * @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" | string; + 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: TextContent | ImageContent | AudioContent; + 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 customer of this object or data is. + * 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"]`). */ @@ -1265,6 +1416,9 @@ export interface Annotations { lastModified?: string; } +/** + * @category Content + */ export type ContentBlock = | TextContent | ImageContent @@ -1274,6 +1428,8 @@ export type ContentBlock = /** * Text provided to or from an LLM. + * + * @category Content */ export interface TextContent { type: "text"; @@ -1296,6 +1452,8 @@ export interface TextContent { /** * An image provided to or from an LLM. + * + * @category Content */ export interface ImageContent { type: "image"; @@ -1325,6 +1483,8 @@ export interface ImageContent { /** * Audio provided to or from an LLM. + * + * @category Content */ export interface AudioContent { type: "audio"; @@ -1352,6 +1512,87 @@ export interface AudioContent { _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. * @@ -1364,6 +1605,8 @@ export interface AudioContent { * 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 { /** @@ -1416,6 +1659,8 @@ export interface ModelPreferences { * * 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 { /** @@ -1436,7 +1681,7 @@ export interface ModelHint { /** * Parameters for a `completion/complete` request. * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteRequestParams extends RequestParams { ref: PromptReference | ResourceTemplateReference; @@ -1468,7 +1713,7 @@ export interface CompleteRequestParams extends RequestParams { /** * A request from the client to the server, to ask for completion options. * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteRequest extends JSONRPCRequest { method: "completion/complete"; @@ -1478,7 +1723,7 @@ export interface CompleteRequest extends JSONRPCRequest { /** * The server's response to a completion/complete request * - * @category completion/complete + * @category `completion/complete` */ export interface CompleteResult extends Result { completion: { @@ -1499,6 +1744,8 @@ export interface CompleteResult extends Result { /** * A reference to a resource or resource template definition. + * + * @category `completion/complete` */ export interface ResourceTemplateReference { type: "ref/resource"; @@ -1512,6 +1759,8 @@ export interface ResourceTemplateReference { /** * Identifies a prompt. + * + * @category `completion/complete` */ export interface PromptReference extends BaseMetadata { type: "ref/prompt"; @@ -1527,7 +1776,7 @@ export interface PromptReference extends BaseMetadata { * 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 + * @category `roots/list` */ export interface ListRootsRequest extends JSONRPCRequest { method: "roots/list"; @@ -1539,7 +1788,7 @@ export interface ListRootsRequest extends JSONRPCRequest { * This result contains an array of Root objects, each representing a root directory * or file that the server can operate on. * - * @category roots/list + * @category `roots/list` */ export interface ListRootsResult extends Result { roots: Root[]; @@ -1547,6 +1796,8 @@ export interface ListRootsResult extends Result { /** * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` */ export interface Root { /** @@ -1575,7 +1826,7 @@ export interface Root { * 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 + * @category `notifications/roots/list_changed` */ export interface RootsListChangedNotification extends JSONRPCNotification { method: "notifications/roots/list_changed"; @@ -1583,20 +1834,27 @@ export interface RootsListChangedNotification extends JSONRPCNotification { } /** - * Parameters for an `elicitation/create` request. + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. * - * @category elicitation/create + * @category `elicitation/create` */ -export interface ElicitRequestParams extends RequestParams { +export interface ElicitRequestFormParams extends RequestParams { + /** + * The elicitation mode. + */ + mode?: "form"; + /** - * The message to present to the user. + * 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; @@ -1605,10 +1863,49 @@ export interface ElicitRequestParams extends RequestParams { }; } +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends RequestParams { + /** + * 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 + * @category `elicitation/create` */ export interface ElicitRequest extends JSONRPCRequest { method: "elicitation/create"; @@ -1618,6 +1915,8 @@ export interface ElicitRequest extends JSONRPCRequest { /** * Restricted schema definitions that only allow primitive types * without nested objects or arrays. + * + * @category `elicitation/create` */ export type PrimitiveSchemaDefinition = | StringSchema @@ -1625,6 +1924,9 @@ export type PrimitiveSchemaDefinition = | BooleanSchema | EnumSchema; +/** + * @category `elicitation/create` + */ export interface StringSchema { type: "string"; title?: string; @@ -1635,6 +1937,9 @@ export interface StringSchema { default?: string; } +/** + * @category `elicitation/create` + */ export interface NumberSchema { type: "number" | "integer"; title?: string; @@ -1644,6 +1949,9 @@ export interface NumberSchema { default?: number; } +/** + * @category `elicitation/create` + */ export interface BooleanSchema { type: "boolean"; title?: string; @@ -1653,6 +1961,8 @@ export interface BooleanSchema { /** * Schema for single-selection enumeration without display titles for options. + * + * @category `elicitation/create` */ export interface UntitledSingleSelectEnumSchema { type: "string"; @@ -1676,6 +1986,8 @@ export interface UntitledSingleSelectEnumSchema { /** * Schema for single-selection enumeration with display titles for each option. + * + * @category `elicitation/create` */ export interface TitledSingleSelectEnumSchema { type: "string"; @@ -1706,6 +2018,9 @@ export interface TitledSingleSelectEnumSchema { default?: string; } +/** + * @category `elicitation/create` + */ // Combined single selection enumeration export type SingleSelectEnumSchema = | UntitledSingleSelectEnumSchema @@ -1713,6 +2028,8 @@ export type SingleSelectEnumSchema = /** * Schema for multiple-selection enumeration without display titles for options. + * + * @category `elicitation/create` */ export interface UntitledMultiSelectEnumSchema { type: "array"; @@ -1750,6 +2067,8 @@ export interface UntitledMultiSelectEnumSchema { /** * Schema for multiple-selection enumeration with display titles for each option. + * + * @category `elicitation/create` */ export interface TitledMultiSelectEnumSchema { type: "array"; @@ -1793,6 +2112,9 @@ export interface TitledMultiSelectEnumSchema { default?: string[]; } +/** + * @category `elicitation/create` + */ // Combined multiple selection enumeration export type MultiSelectEnumSchema = | UntitledMultiSelectEnumSchema @@ -1801,6 +2123,8 @@ export type MultiSelectEnumSchema = /** * Use TitledSingleSelectEnumSchema instead. * This interface will be removed in a future version. + * + * @category `elicitation/create` */ export interface LegacyTitledEnumSchema { type: "string"; @@ -1815,6 +2139,9 @@ export interface LegacyTitledEnumSchema { default?: string; } +/** + * @category `elicitation/create` + */ // Union type for all enum schemas export type EnumSchema = | SingleSelectEnumSchema @@ -1824,7 +2151,7 @@ export type EnumSchema = /** * The client's response to an elicitation request. * - * @category elicitation/create + * @category `elicitation/create` */ export interface ElicitResult extends Result { /** @@ -1836,12 +2163,28 @@ export interface ElicitResult extends Result { action: "accept" | "decline" | "cancel"; /** - * The submitted form data, only present when action is "accept". + * 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 = @@ -1889,7 +2232,8 @@ export type ServerNotification = | ResourceUpdatedNotification | ResourceListChangedNotification | ToolListChangedNotification - | PromptListChangedNotification; + | PromptListChangedNotification + | ElicitationCompleteNotification; /** @internal */ export type ServerResult = diff --git a/src/types.capabilities.test.ts b/src/types.capabilities.test.ts new file mode 100644 index 000000000..67a8ceeb9 --- /dev/null +++ b/src/types.capabilities.test.ts @@ -0,0 +1,103 @@ +import { ClientCapabilitiesSchema, InitializeRequestParamsSchema } from './types.js'; + +describe('ClientCapabilitiesSchema backwards compatibility', () => { + describe('ElicitationCapabilitySchema preprocessing', () => { + it('should inject form capability when elicitation is an empty object', () => { + const capabilities = { + elicitation: {} + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should preserve form capability configuration including applyDefaults', () => { + const capabilities = { + elicitation: { + form: { + applyDefaults: true + } + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({ applyDefaults: true }); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when form is explicitly declared', () => { + const capabilities = { + elicitation: { + form: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toBeUndefined(); + }); + + it('should not inject form capability when url is explicitly declared', () => { + const capabilities = { + elicitation: { + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.url).toEqual({}); + expect(result.elicitation?.form).toBeUndefined(); + }); + + it('should not inject form capability when both form and url are explicitly declared', () => { + const capabilities = { + elicitation: { + form: {}, + url: {} + } + }; + + const result = ClientCapabilitiesSchema.parse(capabilities); + expect(result.elicitation).toBeDefined(); + expect(result.elicitation?.form).toBeDefined(); + expect(result.elicitation?.url).toBeDefined(); + expect(result.elicitation?.form).toEqual({}); + expect(result.elicitation?.url).toEqual({}); + }); + + it('should not inject form capability when elicitation is undefined', () => { + const capabilities = {}; + + const result = ClientCapabilitiesSchema.parse(capabilities); + // When elicitation is not provided, it should remain undefined + expect(result.elicitation).toBeUndefined(); + }); + + it('should work within InitializeRequestParamsSchema context', () => { + const initializeParams = { + protocolVersion: '2025-11-25', + capabilities: { + elicitation: {} + }, + clientInfo: { + name: 'test client', + version: '1.0' + } + }; + + const result = InitializeRequestParamsSchema.parse(initializeParams); + expect(result.capabilities.elicitation).toBeDefined(); + expect(result.capabilities.elicitation?.form).toBeDefined(); + expect(result.capabilities.elicitation?.form).toEqual({}); + }); + }); +}); diff --git a/src/types.test.ts b/src/types.test.ts index cd8cc0711..e6ea0b6d6 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -5,7 +5,15 @@ import { ContentBlockSchema, PromptMessageSchema, CallToolResultSchema, - CompleteRequestSchema + CompleteRequestSchema, + ToolSchema, + ToolUseContentSchema, + ToolResultContentSchema, + ToolChoiceSchema, + SamplingMessageSchema, + CreateMessageRequestSchema, + CreateMessageResultSchema, + ClientCapabilitiesSchema } from './types.js'; describe('Types', () => { @@ -311,4 +319,601 @@ describe('Types', () => { } }); }); + + describe('ToolSchema - JSON Schema 2020-12 support', () => { + test('should accept inputSchema with $schema field', () => { + const tool = { + name: 'test', + inputSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { name: { type: 'string' } } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with additionalProperties', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with composition keywords', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + allOf: [{ properties: { a: { type: 'string' } } }, { properties: { b: { type: 'number' } } }] + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with $ref and $defs', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { user: { $ref: '#/$defs/User' } }, + $defs: { + User: { type: 'object', properties: { name: { type: 'string' } } } + } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept inputSchema with metadata keywords', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + title: 'User Input', + description: 'Input parameters for user creation', + deprecated: false, + examples: [{ name: 'John' }], + properties: { name: { type: 'string' } } + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should accept outputSchema with full JSON Schema features', () => { + const tool = { + name: 'test', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'object', + properties: { + id: { type: 'string' }, + tags: { type: 'array' } + }, + required: ['id'], + additionalProperties: false, + minProperties: 1 + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + + test('should still require type: object at root for inputSchema', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'string' + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(false); + }); + + test('should still require type: object at root for outputSchema', () => { + const tool = { + name: 'test', + inputSchema: { type: 'object' }, + outputSchema: { + type: 'array' + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(false); + }); + + test('should accept simple minimal schema (backward compatibility)', () => { + const tool = { + name: 'test', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'] + } + }; + const result = ToolSchema.safeParse(tool); + expect(result.success).toBe(true); + }); + }); + + describe('ToolUseContent', () => { + test('should validate a tool call content', () => { + const toolCall = { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'San Francisco', units: 'celsius' } + }; + + const result = ToolUseContentSchema.safeParse(toolCall); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('tool_use'); + expect(result.data.id).toBe('call_123'); + expect(result.data.name).toBe('get_weather'); + expect(result.data.input).toEqual({ city: 'San Francisco', units: 'celsius' }); + } + }); + + test('should validate tool call with _meta', () => { + const toolCall = { + type: 'tool_use', + id: 'call_456', + name: 'search', + input: { query: 'test' }, + _meta: { custom: 'data' } + }; + + const result = ToolUseContentSchema.safeParse(toolCall); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data._meta).toEqual({ custom: 'data' }); + } + }); + + test('should fail validation for missing required fields', () => { + const invalidToolCall = { + type: 'tool_use', + name: 'test' + // missing id and input + }; + + const result = ToolUseContentSchema.safeParse(invalidToolCall); + expect(result.success).toBe(false); + }); + }); + + describe('ToolResultContent', () => { + test('should validate a tool result content', () => { + const toolResult = { + type: 'tool_result', + toolUseId: 'call_123', + structuredContent: { temperature: 72, condition: 'sunny' } + }; + + const result = ToolResultContentSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('tool_result'); + expect(result.data.toolUseId).toBe('call_123'); + expect(result.data.structuredContent).toEqual({ temperature: 72, condition: 'sunny' }); + } + }); + + test('should validate tool result with error in content', () => { + const toolResult = { + type: 'tool_result', + toolUseId: 'call_456', + structuredContent: { error: 'API_ERROR', message: 'Service unavailable' }, + isError: true + }; + + const result = ToolResultContentSchema.safeParse(toolResult); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.structuredContent).toEqual({ error: 'API_ERROR', message: 'Service unavailable' }); + expect(result.data.isError).toBe(true); + } + }); + + test('should fail validation for missing required fields', () => { + const invalidToolResult = { + type: 'tool_result', + content: { data: 'test' } + // missing toolUseId + }; + + const result = ToolResultContentSchema.safeParse(invalidToolResult); + expect(result.success).toBe(false); + }); + }); + + describe('ToolChoice', () => { + test('should validate tool choice with mode auto', () => { + const toolChoice = { + mode: 'auto' + }; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mode).toBe('auto'); + } + }); + + test('should validate tool choice with mode required', () => { + const toolChoice = { + mode: 'required' + }; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mode).toBe('required'); + } + }); + + test('should validate empty tool choice', () => { + const toolChoice = {}; + + const result = ToolChoiceSchema.safeParse(toolChoice); + expect(result.success).toBe(true); + }); + + test('should fail validation for invalid mode', () => { + const invalidToolChoice = { + mode: 'invalid' + }; + + const result = ToolChoiceSchema.safeParse(invalidToolChoice); + expect(result.success).toBe(false); + }); + }); + + describe('SamplingMessage content types', () => { + test('should validate user message with text', () => { + const userMessage = { + role: 'user', + content: { type: 'text', text: "What's the weather?" } + }; + + const result = SamplingMessageSchema.safeParse(userMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('user'); + if (!Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('text'); + } + } + }); + + test('should validate user message with tool result', () => { + const userMessage = { + role: 'user', + content: { + type: 'tool_result', + toolUseId: 'call_123', + content: [] + } + }; + + const result = SamplingMessageSchema.safeParse(userMessage); + expect(result.success).toBe(true); + if (result.success && !Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('tool_result'); + } + }); + + test('should validate assistant message with text', () => { + const assistantMessage = { + role: 'assistant', + content: { type: 'text', text: "I'll check the weather for you." } + }; + + const result = SamplingMessageSchema.safeParse(assistantMessage); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('assistant'); + } + }); + + test('should validate assistant message with tool call', () => { + const assistantMessage = { + role: 'assistant', + content: { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + } + }; + + const result = SamplingMessageSchema.safeParse(assistantMessage); + expect(result.success).toBe(true); + if (result.success && !Array.isArray(result.data.content)) { + expect(result.data.content.type).toBe('tool_use'); + } + }); + + test('should validate any content type for any role', () => { + // The simplified schema allows any content type for any role + const assistantWithToolResult = { + role: 'assistant', + content: { + type: 'tool_result', + toolUseId: 'call_123', + content: [] + } + }; + + const result1 = SamplingMessageSchema.safeParse(assistantWithToolResult); + expect(result1.success).toBe(true); + + const userWithToolUse = { + role: 'user', + content: { + type: 'tool_use', + id: 'call_123', + name: 'test', + input: {} + } + }; + + const result2 = SamplingMessageSchema.safeParse(userWithToolUse); + expect(result2.success).toBe(true); + }); + }); + + describe('SamplingMessage', () => { + test('should validate user message via discriminated union', () => { + const message = { + role: 'user', + content: { type: 'text', text: 'Hello' } + }; + + const result = SamplingMessageSchema.safeParse(message); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('user'); + } + }); + + test('should validate assistant message via discriminated union', () => { + const message = { + role: 'assistant', + content: { type: 'text', text: 'Hi there!' } + }; + + const result = SamplingMessageSchema.safeParse(message); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.role).toBe('assistant'); + } + }); + }); + + describe('CreateMessageRequest', () => { + test('should validate request without tools', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + maxTokens: 1000 + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.tools).toBeUndefined(); + } + }); + + test('should validate request with tools', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: "What's the weather?" } }], + maxTokens: 1000, + tools: [ + { + name: 'get_weather', + description: 'Get weather for a location', + inputSchema: { + type: 'object', + properties: { + location: { type: 'string' } + }, + required: ['location'] + } + } + ], + toolChoice: { + mode: 'auto' + } + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.tools).toHaveLength(1); + expect(result.data.params.toolChoice?.mode).toBe('auto'); + } + }); + + test('should validate request with includeContext (soft-deprecated)', () => { + const request = { + method: 'sampling/createMessage', + params: { + messages: [{ role: 'user', content: { type: 'text', text: 'Help' } }], + maxTokens: 1000, + includeContext: 'thisServer' + } + }; + + const result = CreateMessageRequestSchema.safeParse(request); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.params.includeContext).toBe('thisServer'); + } + }); + }); + + describe('CreateMessageResult', () => { + test('should validate result with text content', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: { type: 'text', text: "Here's the answer." }, + stopReason: 'endTurn' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.role).toBe('assistant'); + expect(parseResult.data.stopReason).toBe('endTurn'); + } + }); + + test('should validate result with tool call', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + }, + stopReason: 'toolUse' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.stopReason).toBe('toolUse'); + const content = parseResult.data.content; + expect(Array.isArray(content)).toBe(false); + if (!Array.isArray(content)) { + expect(content.type).toBe('tool_use'); + } + } + }); + + test('should validate result with array content', () => { + const result = { + model: 'claude-3-5-sonnet-20241022', + role: 'assistant', + content: [ + { type: 'text', text: 'Let me check the weather.' }, + { + type: 'tool_use', + id: 'call_123', + name: 'get_weather', + input: { city: 'SF' } + } + ], + stopReason: 'toolUse' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + if (parseResult.success) { + expect(parseResult.data.stopReason).toBe('toolUse'); + const content = parseResult.data.content; + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content).toHaveLength(2); + expect(content[0].type).toBe('text'); + expect(content[1].type).toBe('tool_use'); + } + } + }); + + test('should validate all new stop reasons', () => { + const stopReasons = ['endTurn', 'stopSequence', 'maxTokens', 'toolUse', 'refusal', 'other']; + + stopReasons.forEach(stopReason => { + const result = { + model: 'test', + role: 'assistant', + content: { type: 'text', text: 'test' }, + stopReason + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + }); + }); + + test('should allow custom stop reason string', () => { + const result = { + model: 'test', + role: 'assistant', + content: { type: 'text', text: 'test' }, + stopReason: 'custom_provider_reason' + }; + + const parseResult = CreateMessageResultSchema.safeParse(result); + expect(parseResult.success).toBe(true); + }); + }); + + describe('ClientCapabilities with sampling', () => { + test('should validate capabilities with sampling.tools', () => { + const capabilities = { + sampling: { + tools: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.tools).toBeDefined(); + } + }); + + test('should validate capabilities with sampling.context', () => { + const capabilities = { + sampling: { + context: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.context).toBeDefined(); + } + }); + + test('should validate capabilities with both', () => { + const capabilities = { + sampling: { + context: {}, + tools: {} + } + }; + + const result = ClientCapabilitiesSchema.safeParse(capabilities); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.sampling?.context).toBeDefined(); + expect(result.data.sampling?.tools).toBeDefined(); + } + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 66cc34941..5f34ed1b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { z, ZodTypeAny } from 'zod'; +import * as z from 'zod/v4'; import { AuthInfo } from './server/auth/types.js'; export const LATEST_PROTOCOL_VERSION = '2025-06-18'; @@ -28,22 +28,17 @@ export const ProgressTokenSchema = z.union([z.string(), z.number().int()]); */ export const CursorSchema = z.string(); -const RequestMetaSchema = z - .object({ - /** - * 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() - }) +const RequestMetaSchema = z.looseObject({ /** - * Passthrough required here because we want to allow any additional fields to be added to the request 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. */ - .passthrough(); + progressToken: ProgressTokenSchema.optional() +}); /** * Common params for any request. */ -const BaseRequestParamsSchema = z.object({ +const BaseRequestParamsSchema = z.looseObject({ /** * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage. */ @@ -52,10 +47,10 @@ const BaseRequestParamsSchema = z.object({ export const RequestSchema = z.object({ method: z.string(), - params: BaseRequestParamsSchema.passthrough().optional() + params: BaseRequestParamsSchema.optional() }); -const NotificationsParamsSchema = z.object({ +const NotificationsParamsSchema = 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. @@ -65,21 +60,16 @@ const NotificationsParamsSchema = z.object({ export const NotificationSchema = z.object({ method: z.string(), - params: NotificationsParamsSchema.passthrough().optional() + params: NotificationsParamsSchema.optional() }); -export const ResultSchema = 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: z.record(z.string(), z.unknown()).optional() - }) +export const ResultSchema = z.looseObject({ /** - * Passthrough required here because we want to allow any additional fields to be added to the result. + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. */ - .passthrough(); + _meta: z.record(z.string(), z.unknown()).optional() +}); /** * A uniquely identifying ID for a request in JSON-RPC. @@ -92,9 +82,9 @@ export const RequestIdSchema = z.union([z.string(), z.number().int()]); export const JSONRPCRequestSchema = z .object({ jsonrpc: z.literal(JSONRPC_VERSION), - id: RequestIdSchema + id: RequestIdSchema, + ...RequestSchema.shape }) - .merge(RequestSchema) .strict(); export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => JSONRPCRequestSchema.safeParse(value).success; @@ -104,9 +94,9 @@ export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest => JSO */ export const JSONRPCNotificationSchema = z .object({ - jsonrpc: z.literal(JSONRPC_VERSION) + jsonrpc: z.literal(JSONRPC_VERSION), + ...NotificationSchema.shape }) - .merge(NotificationSchema) .strict(); export const isJSONRPCNotification = (value: unknown): value is JSONRPCNotification => JSONRPCNotificationSchema.safeParse(value).success; @@ -137,7 +127,10 @@ export enum ErrorCode { InvalidRequest = -32600, MethodNotFound = -32601, InvalidParams = -32602, - InternalError = -32603 + InternalError = -32603, + + // MCP-specific error codes + UrlElicitationRequired = -32042 } /** @@ -264,12 +257,39 @@ export const BaseMetadataSchema = z.object({ * 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() -}).merge(IconsSchema); +}); + +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() + ) +); /** * 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. @@ -282,21 +302,23 @@ export const ClientCapabilitiesSchema = z.object({ /** * Present if the client supports sampling from an LLM. */ - sampling: AssertObjectSchema.optional(), + 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: z.intersection( - z - .object({ - /** - * Whether the client should apply defaults to the user input. - */ - applyDefaults: z.boolean().optional() - }) - .optional(), - z.record(z.string(), z.unknown()).optional() - ), + elicitation: ElicitationCapabilitySchema.optional(), /** * Present if the client supports listing roots. */ @@ -436,7 +458,9 @@ export const ProgressSchema = z.object({ message: z.optional(z.string()) }); -export const ProgressNotificationParamsSchema = NotificationsParamsSchema.merge(ProgressSchema).extend({ +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. */ @@ -529,7 +553,9 @@ export const BlobResourceContentsSchema = ResourceContentsSchema.extend({ /** * A known resource that the server is capable of reading. */ -export const ResourceSchema = BaseMetadataSchema.extend({ +export const ResourceSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, /** * The URI of this resource. */ @@ -551,13 +577,15 @@ export const ResourceSchema = BaseMetadataSchema.extend({ * 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.object({}).passthrough()) -}).merge(IconsSchema); + _meta: z.optional(z.looseObject({})) +}); /** * A template description for resources available on the server. */ -export const ResourceTemplateSchema = BaseMetadataSchema.extend({ +export const ResourceTemplateSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, /** * A URI template (according to RFC 6570) that can be used to construct resource URIs. */ @@ -579,8 +607,8 @@ export const ResourceTemplateSchema = BaseMetadataSchema.extend({ * 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.object({}).passthrough()) -}).merge(IconsSchema); + _meta: z.optional(z.looseObject({})) +}); /** * Sent from the client to request a list of resources the server has. @@ -704,7 +732,9 @@ export const PromptArgumentSchema = z.object({ /** * A prompt or prompt template that the server offers. */ -export const PromptSchema = BaseMetadataSchema.extend({ +export const PromptSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, /** * An optional description of what this prompt provides */ @@ -717,8 +747,8 @@ export const PromptSchema = BaseMetadataSchema.extend({ * 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.object({}).passthrough()) -}).merge(IconsSchema); + _meta: z.optional(z.looseObject({})) +}); /** * Sent from the client to request a list of prompts and prompt templates the server has. @@ -814,6 +844,36 @@ export const AudioContentSchema = z.object({ _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.object({}).passthrough(), + /** + * 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.object({}).passthrough()) + }) + .passthrough(); + /** * The contents of a resource, embedded into a prompt or tool call result. */ @@ -931,33 +991,36 @@ export const ToolAnnotationsSchema = z.object({ /** * Definition for a tool the client can call. */ -export const ToolSchema = BaseMetadataSchema.extend({ +export const ToolSchema = z.object({ + ...BaseMetadataSchema.shape, + ...IconsSchema.shape, /** * A human-readable description of the tool. */ description: z.string().optional(), /** - * A JSON Schema object defining the expected parameters for the tool. + * 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.optional(z.array(z.string())) - }), + 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 object defining the structure of the tool's output returned in - * the structuredContent field of a CallToolResult. + * 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.optional(z.array(z.string())), - /** - * Not in the MCP specification, but added to support the Ajv validator while removing .passthrough() which previously allowed additionalProperties to be passed through. - */ - additionalProperties: z.optional(z.boolean()) + required: z.array(z.string()).optional() }) + .catchall(z.unknown()) .optional(), /** * Optional additional tool information. @@ -969,7 +1032,7 @@ export const ToolSchema = BaseMetadataSchema.extend({ * for notes on _meta usage. */ _meta: z.record(z.string(), z.unknown()).optional() -}).merge(IconsSchema); +}); /** * Sent from the client to request a list of tools the server has. @@ -1141,13 +1204,65 @@ export const ModelPreferencesSchema = z.object({ }); /** - * Describes a message issued to or received from an LLM API. + * Controls tool usage behavior in sampling requests. */ -export const SamplingMessageSchema = z.object({ - role: z.enum(['user', 'assistant']), - content: z.union([TextContentSchema, ImageContentSchema, AudioContentSchema]) +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.optional(z.enum(['auto', 'required', 'none'])) }); +/** + * 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({}).passthrough().optional(), + isError: z.optional(z.boolean()), + + /** + * 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.object({}).passthrough()) + }) + .passthrough(); + +/** + * 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: z.enum(['user', 'assistant']), + 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.optional(z.object({}).passthrough()) + }) + .passthrough(); + /** * Parameters for a `sampling/createMessage` request. */ @@ -1162,7 +1277,11 @@ export const CreateMessageRequestParamsSchema = BaseRequestParamsSchema.extend({ */ 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. + * 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(), @@ -1176,7 +1295,18 @@ export const CreateMessageRequestParamsSchema = BaseRequestParamsSchema.extend({ /** * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. */ - metadata: AssertObjectSchema.optional() + 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.optional(z.array(ToolSchema)), + /** + * 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: z.optional(ToolChoiceSchema) }); /** * 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. @@ -1195,11 +1325,22 @@ export const CreateMessageResultSchema = ResultSchema.extend({ */ model: z.string(), /** - * The reason why sampling stopped. + * 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']).or(z.string())), + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens', 'toolUse']).or(z.string())), role: z.enum(['user', 'assistant']), - content: z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]) + /** + * Response content. May be ToolUseContent if stopReason is "toolUse". + */ + content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]) }); /* Elicitation */ @@ -1333,11 +1474,17 @@ export const EnumSchemaSchema = z.union([LegacyTitledEnumSchemaSchema, SingleSel export const PrimitiveSchemaDefinitionSchema = z.union([EnumSchemaSchema, BooleanSchemaSchema, StringSchemaSchema, NumberSchemaSchema]); /** - * Parameters for an `elicitation/create` request. + * Parameters for an `elicitation/create` request for form-based elicitation. */ -export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({ +export const ElicitRequestFormParamsSchema = BaseRequestParamsSchema.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. + * The message to present to the user describing what information is being requested. */ message: z.string(), /** @@ -1351,15 +1498,66 @@ export const ElicitRequestParamsSchema = BaseRequestParamsSchema.extend({ }) }); +/** + * Parameters for an `elicitation/create` request for URL-based elicitation. + */ +export const ElicitRequestURLParamsSchema = BaseRequestParamsSchema.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. + * 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. */ @@ -1375,7 +1573,7 @@ export const ElicitResultSchema = ResultSchema.extend({ * The submitted form data, only present when action is "accept". * Contains values matching the requested schema. */ - content: z.record(z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() + content: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional() }); /* Autocomplete */ @@ -1445,34 +1643,34 @@ export function assertCompleteRequestPrompt(request: CompleteRequest): asserts r 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 - .object({ - /** - * 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()) - }) - .passthrough() + 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 */ @@ -1553,7 +1751,8 @@ export const ServerNotificationSchema = z.union([ ResourceUpdatedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, - PromptListChangedNotificationSchema + PromptListChangedNotificationSchema, + ElicitationCompleteNotificationSchema ]); export const ServerResultSchema = z.union([ @@ -1578,6 +1777,38 @@ export class McpError extends Error { 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; @@ -1593,7 +1824,7 @@ type Flatten = T extends Primitive ? { [K in keyof T]: Flatten } : T; -type Infer = Flatten>; +type Infer = Flatten>; /** * Headers that are compatible with both Node.js and the browser. @@ -1707,6 +1938,8 @@ 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; @@ -1733,8 +1966,10 @@ export type LoggingMessageNotificationParams = Infer; /* Sampling */ +export type ToolChoice = Infer; export type ModelHint = Infer; export type ModelPreferences = Infer; +export type SamplingMessageContentBlock = Infer; export type SamplingMessage = Infer; export type CreateMessageRequestParams = Infer; export type CreateMessageRequest = Infer; @@ -1756,7 +1991,11 @@ 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 */ @@ -1769,11 +2008,9 @@ export type PromptReference = Infer; export type CompleteRequestParams = Infer; export type CompleteRequest = Infer; export type CompleteRequestResourceTemplate = ExpandRecursively< - Omit & { params: Omit & { ref: ResourceTemplateReference } } ->; -export type CompleteRequestPrompt = ExpandRecursively< - Omit & { params: Omit & { ref: PromptReference } } + CompleteRequest & { params: CompleteRequestParams & { ref: ResourceTemplateReference } } >; +export type CompleteRequestPrompt = ExpandRecursively; export type CompleteResult = Infer; /* Roots */ diff --git a/src/validation/cfworker-provider.ts b/src/validation/cfworker-provider.ts index 60ec3f06e..adb102037 100644 --- a/src/validation/cfworker-provider.ts +++ b/src/validation/cfworker-provider.ts @@ -4,7 +4,6 @@ * 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 { type Schema, Validator } from '@cfworker/json-schema'; diff --git a/src/validation/validation.test.ts b/src/validation/validation.test.ts index ef2e77090..6c2f6668f 100644 --- a/src/validation/validation.test.ts +++ b/src/validation/validation.test.ts @@ -6,6 +6,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { vi } from 'vitest'; import { AjvJsonSchemaValidator } from './ajv-provider.js'; import { CfWorkerJsonSchemaValidator } from './cfworker-provider.js'; @@ -533,21 +534,21 @@ describe('JSON Schema Validators', () => { describe('Missing dependencies', () => { describe('AJV not installed but CfWorker is', () => { beforeEach(() => { - jest.resetModules(); + vi.resetModules(); }); afterEach(() => { - jest.unmock('ajv'); - jest.unmock('ajv-formats'); + vi.doUnmock('ajv'); + vi.doUnmock('ajv-formats'); }); it('should throw error when trying to import ajv-provider without ajv', async () => { // Mock ajv as not installed - jest.doMock('ajv', () => { + vi.doMock('ajv', () => { throw new Error("Cannot find module 'ajv'"); }); - jest.doMock('ajv-formats', () => { + vi.doMock('ajv-formats', () => { throw new Error("Cannot find module 'ajv-formats'"); }); @@ -557,11 +558,11 @@ describe('Missing dependencies', () => { it('should be able to import cfworker-provider when ajv is missing', async () => { // Mock ajv as not installed - jest.doMock('ajv', () => { + vi.doMock('ajv', () => { throw new Error("Cannot find module 'ajv'"); }); - jest.doMock('ajv-formats', () => { + vi.doMock('ajv-formats', () => { throw new Error("Cannot find module 'ajv-formats'"); }); @@ -579,16 +580,16 @@ describe('Missing dependencies', () => { describe('CfWorker not installed but AJV is', () => { beforeEach(() => { - jest.resetModules(); + vi.resetModules(); }); afterEach(() => { - jest.unmock('@cfworker/json-schema'); + vi.doUnmock('@cfworker/json-schema'); }); it('should throw error when trying to import cfworker-provider without @cfworker/json-schema', async () => { // Mock @cfworker/json-schema as not installed - jest.doMock('@cfworker/json-schema', () => { + vi.doMock('@cfworker/json-schema', () => { throw new Error("Cannot find module '@cfworker/json-schema'"); }); @@ -598,7 +599,7 @@ describe('Missing dependencies', () => { it('should be able to import ajv-provider when @cfworker/json-schema is missing', async () => { // Mock @cfworker/json-schema as not installed - jest.doMock('@cfworker/json-schema', () => { + vi.doMock('@cfworker/json-schema', () => { throw new Error("Cannot find module '@cfworker/json-schema'"); }); diff --git a/tsconfig.json b/tsconfig.json index b85703889..a146fb03d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "skipLibCheck": true, "paths": { "pkce-challenge": ["./node_modules/pkce-challenge/dist/index.node"] - } + }, + "types": ["node", "vitest/globals"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 8fc00fcd4..fcf2e951c 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "./dist/esm" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..2af7cfb6c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node' + } +});