From 205d379acd23362d42633fc7f18cf998c3a55608 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:09:25 -0800 Subject: [PATCH 01/13] chore: fix merge conflicts with latest --- .prettierignore | 3 + README.md | 12 +- ZOD_V4_COMPATIBILITY_APPROACH.md | 678 +++ eslint.config.mjs | 13 + package-lock.json | 626 +-- package.json | 9 +- src/_vendor/zod-to-json-schema/LICENSE | 15 + src/_vendor/zod-to-json-schema/Options.ts | 80 + src/_vendor/zod-to-json-schema/README.md | 3 + src/_vendor/zod-to-json-schema/Refs.ts | 45 + .../zod-to-json-schema/errorMessages.ts | 31 + src/_vendor/zod-to-json-schema/index.ts | 38 + src/_vendor/zod-to-json-schema/parseDef.ts | 258 + src/_vendor/zod-to-json-schema/parsers/any.ts | 5 + .../zod-to-json-schema/parsers/array.ts | 36 + .../zod-to-json-schema/parsers/bigint.ts | 60 + .../zod-to-json-schema/parsers/boolean.ts | 9 + .../zod-to-json-schema/parsers/branded.ts | 7 + .../zod-to-json-schema/parsers/catch.ts | 7 + .../zod-to-json-schema/parsers/date.ts | 83 + .../zod-to-json-schema/parsers/default.ts | 10 + .../zod-to-json-schema/parsers/effects.ts | 11 + .../zod-to-json-schema/parsers/enum.ts | 13 + .../parsers/intersection.ts | 64 + .../zod-to-json-schema/parsers/literal.ts | 37 + src/_vendor/zod-to-json-schema/parsers/map.ts | 42 + .../zod-to-json-schema/parsers/nativeEnum.ts | 27 + .../zod-to-json-schema/parsers/never.ts | 9 + .../zod-to-json-schema/parsers/null.ts | 16 + .../zod-to-json-schema/parsers/nullable.ts | 49 + .../zod-to-json-schema/parsers/number.ts | 62 + .../zod-to-json-schema/parsers/object.ts | 76 + .../zod-to-json-schema/parsers/optional.ts | 28 + .../zod-to-json-schema/parsers/pipeline.ts | 28 + .../zod-to-json-schema/parsers/promise.ts | 7 + .../zod-to-json-schema/parsers/readonly.ts | 7 + .../zod-to-json-schema/parsers/record.ts | 73 + src/_vendor/zod-to-json-schema/parsers/set.ts | 36 + .../zod-to-json-schema/parsers/string.ts | 400 ++ .../zod-to-json-schema/parsers/tuple.ts | 54 + .../zod-to-json-schema/parsers/undefined.ts | 9 + .../zod-to-json-schema/parsers/union.ts | 119 + .../zod-to-json-schema/parsers/unknown.ts | 5 + src/_vendor/zod-to-json-schema/util.ts | 11 + .../zod-to-json-schema/zodToJsonSchema.ts | 120 + src/client/index.test.ts | 2 +- src/client/index.ts | 75 +- src/client/v3/index.v3.test.ts | 1238 +++++ .../server/jsonResponseStreamableHttp.ts | 2 +- src/examples/server/mcpServerOutputSchema.ts | 2 +- src/examples/server/simpleSseServer.ts | 2 +- .../server/simpleStatelessStreamableHttp.ts | 2 +- src/examples/server/simpleStreamableHttp.ts | 2 +- .../sseAndStreamableHttpCompatibleServer.ts | 2 +- src/examples/server/toolWithSampleServer.ts | 2 +- .../stateManagementStreamableHttp.test.ts | 2 +- .../taskResumability.test.ts | 2 +- .../stateManagementStreamableHttp.v3.test.ts | 357 ++ .../v3/taskResumability.v3.test.ts | 270 + src/server/auth/handlers/authorize.ts | 2 +- src/server/auth/handlers/token.ts | 2 +- src/server/auth/middleware/clientAuth.ts | 2 +- src/server/completable.test.ts | 20 +- src/server/completable.ts | 113 +- src/server/index.test.ts | 2 +- src/server/mcp.test.ts | 9 +- src/server/mcp.ts | 217 +- src/server/sse.test.ts | 2 +- src/server/streamableHttp.test.ts | 2 +- src/server/title.test.ts | 2 +- src/server/v3/completable.v3.test.ts | 54 + src/server/v3/index.v3.test.ts | 951 ++++ src/server/v3/mcp.v3.test.ts | 4519 +++++++++++++++++ src/server/v3/sse.v3.test.ts | 711 +++ src/server/v3/streamableHttp.v3.test.ts | 2151 ++++++++ src/server/v3/title.v3.test.ts | 224 + src/server/zod-compat.ts | 190 + src/server/zod-json-schema-compat.ts | 45 + src/shared/auth.ts | 2 +- .../protocol-transport-handling.test.ts | 2 +- src/shared/protocol.ts | 126 +- src/types.ts | 6 +- 82 files changed, 13819 insertions(+), 826 deletions(-) create mode 100644 ZOD_V4_COMPATIBILITY_APPROACH.md create mode 100644 src/_vendor/zod-to-json-schema/LICENSE create mode 100644 src/_vendor/zod-to-json-schema/Options.ts create mode 100644 src/_vendor/zod-to-json-schema/README.md create mode 100644 src/_vendor/zod-to-json-schema/Refs.ts create mode 100644 src/_vendor/zod-to-json-schema/errorMessages.ts create mode 100644 src/_vendor/zod-to-json-schema/index.ts create mode 100644 src/_vendor/zod-to-json-schema/parseDef.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/any.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/array.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/bigint.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/boolean.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/branded.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/catch.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/date.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/default.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/effects.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/enum.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/intersection.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/literal.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/map.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/never.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/null.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/nullable.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/number.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/object.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/optional.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/pipeline.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/promise.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/readonly.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/record.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/set.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/string.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/tuple.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/undefined.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/union.ts create mode 100644 src/_vendor/zod-to-json-schema/parsers/unknown.ts create mode 100644 src/_vendor/zod-to-json-schema/util.ts create mode 100644 src/_vendor/zod-to-json-schema/zodToJsonSchema.ts create mode 100644 src/client/v3/index.v3.test.ts create mode 100644 src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts create mode 100644 src/integration-tests/v3/taskResumability.v3.test.ts create mode 100644 src/server/v3/completable.v3.test.ts create mode 100644 src/server/v3/index.v3.test.ts create mode 100644 src/server/v3/mcp.v3.test.ts create mode 100644 src/server/v3/sse.v3.test.ts create mode 100644 src/server/v3/streamableHttp.v3.test.ts create mode 100644 src/server/v3/title.v3.test.ts create mode 100644 src/server/zod-compat.ts create mode 100644 src/server/zod-json-schema-compat.ts diff --git a/.prettierignore b/.prettierignore index ae37f91c7..88de1d1e6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,6 @@ pnpm-lock.yaml # Ignore generated files src/spec.types.ts + +# Ignore vendor files +src/_vendor/ \ No newline at end of file diff --git a/README.md b/README.md index 92f56786f..7c9def22b 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,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({ @@ -477,7 +477,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 +561,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 +796,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 +866,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 +961,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', diff --git a/ZOD_V4_COMPATIBILITY_APPROACH.md b/ZOD_V4_COMPATIBILITY_APPROACH.md new file mode 100644 index 000000000..d2d9e63d2 --- /dev/null +++ b/ZOD_V4_COMPATIBILITY_APPROACH.md @@ -0,0 +1,678 @@ +# Zod v4 Compatibility Approach + +This document outlines the comprehensive approach taken to make the MCP TypeScript SDK compatible with both Zod v3 and Zod v4, allowing users to use either version seamlessly. + +## Overview + +The primary goal was to support both Zod v3 (`^3.25`) and Zod v4 (`^4.0`) without breaking existing code. This required: + +1. **Unified type system** that accepts both v3 and v4 schemas +2. **Runtime detection** to determine which Zod version is being used +3. **Compatibility layer** for operations that differ between versions +4. **Vendored dependencies** for v3-specific functionality +5. **Symbol-based metadata** instead of class inheritance for extensibility +6. **Comprehensive test coverage** for both versions + +## Key Design Decisions + +### 1. Dual Import Strategy + +Zod v4 introduced subpath exports (`zod/v3`, `zod/v4/core`, `zod/v4-mini`) that allow importing specific versions. We leverage this to: + +- Import types from both versions: `import type * as z3 from 'zod/v3'` and `import type * as z4 from 'zod/v4/core'` +- Import runtime implementations: `import * as z3rt from 'zod/v3'` and `import * as z4mini from 'zod/v4-mini'` +- Use v4 Mini as the default for new schemas (smaller bundle size) + +### 2. No Version Mixing + +We enforce that within a single schema shape (e.g., an object's properties), all schemas must be from the same version. Mixed versions throw an error at runtime. + +### 3. Backward Compatibility First + +The SDK maintains full backward compatibility with Zod v3 while adding v4 support. Existing code using v3 continues to work without changes. + +## Core Compatibility Files + +### `src/server/zod-compat.ts` + +This is the **foundation** of the compatibility layer, providing unified types and runtime helpers. + +#### Unified Types + +```typescript +export type AnySchema = z3.ZodTypeAny | z4.$ZodType; +export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject; +export type ZodRawShapeCompat = Record; +``` + +These types accept schemas from either version, allowing the rest of the codebase to work with both. + +#### Type Inference Helpers + +```typescript +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; +``` + +These conditional types correctly infer input/output types based on which version is being used. + +#### Runtime Detection + +```typescript +export function isZ4Schema(s: AnySchema): s is z4.$ZodType { + // Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3 + return !!(s as any)?._zod; +} +``` + +Zod v4 schemas have a `_zod` property that v3 schemas lack. This is the key to runtime version detection. + +#### Schema Construction + +```typescript +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.'); +} +``` + +This function: + +- Detects which version all schemas in a shape belong to +- Constructs the appropriate object schema using the correct version's API +- Throws if versions are mixed (enforcing our "no mixing" rule) + +#### Unified Parsing + +```typescript +export function safeParse(schema: S, data: unknown): { success: true; data: SchemaOutput } | { success: false; error: unknown } { + if (isZ4Schema(schema)) { + // Mini exposes top-level safeParse + return z4mini.safeParse(schema as z4.$ZodType, data) as any; + } + return (schema as z3.ZodTypeAny).safeParse(data) as any; +} +``` + +The parsing API differs between versions: + +- **v3**: Instance method `schema.safeParse(data)` +- **v4**: Top-level function `z4mini.safeParse(schema, data)` + +Our wrapper abstracts this difference. + +### `src/server/zod-json-schema-compat.ts` + +JSON Schema conversion differs significantly between versions, requiring a compatibility layer. + +#### The Challenge + +- **Zod v3**: Uses external library `zod-to-json-schema` (vendored in `_vendor/`) +- **Zod v4**: Has built-in `toJSONSchema` method on schemas + +#### Solution + +```typescript +export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { + if (isZ4Schema(schema)) { + // v4 branch — use Mini's built-in toJSONSchema + return z4mini.toJSONSchema(schema as z4.$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 any + ) as JsonSchema; +} +``` + +#### Option Mapping + +The options API differs between versions: + +- **v3**: `strictUnions`, `pipeStrategy`, `target` (with values like `'jsonSchema7'`) +- **v4**: `target` (with values like `'draft-7'`), `io` (instead of `pipeStrategy`) + +We map between these formats: + +```typescript +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 +} +``` + +### `src/_vendor/zod-to-json-schema/` + +We **vendored** the `zod-to-json-schema` library for v3 compatibility. This means: + +1. **No external dependency** on `zod-to-json-schema` (removed from `package.json`) +2. **Full control** over the implementation +3. **Version-specific imports** - all files import from `'zod/v3'` explicitly +4. **Isolation** - v3 conversion logic is completely separate from v4 + +#### Why Vendor? + +- Zod v4 has built-in JSON Schema conversion, so `zod-to-json-schema` is only needed for v3 +- Vendoring ensures we can fix bugs or make modifications without waiting for upstream +- Reduces dependency surface area +- Allows us to pin to a specific version that works with our codebase + +#### Structure + +The vendored code is a complete copy of `zod-to-json-schema` with: + +- All imports changed to `'zod/v3'` +- All type references updated to use v3 types +- Original LICENSE and README preserved + +### `src/server/completable.ts` + +The completable feature allows schemas to provide autocompletion suggestions. This required a major refactor. + +#### The Problem + +**Before (v3-only):** + +```typescript +export class Completable extends ZodType<...> { + _parse(input: ParseInput): ParseReturnType { + return this._def.type._parse({...}); + } + unwrap() { + return this._def.type; + } +} +``` + +This approach: + +- Extended Zod's base class (not possible in v4 due to API changes) +- Relied on Zod internals (`_def`, `_parse`, etc.) that changed in v4 +- Required deep integration with Zod's type system + +#### The Solution: Symbol-Based Metadata + +**After (v3 + v4 compatible):** + +```typescript +export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); + +export type CompletableSchema = T & { + [COMPLETABLE_SYMBOL]: CompletableMeta; +}; + +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; +} +``` + +This approach: + +- **No inheritance** - works with any schema type from either version +- **Non-intrusive** - doesn't modify parsing behavior +- **Version-agnostic** - uses standard JavaScript symbols +- **Backward compatible** - provides `unwrapCompletable()` helper for code that called `.unwrap()` + +#### Detection + +```typescript +export function isCompletable(schema: unknown): schema is CompletableSchema { + return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object); +} + +export function getCompleter(schema: T): CompleteCallback | undefined { + const meta = (schema as any)[COMPLETABLE_SYMBOL] as CompletableMeta | undefined; + return meta?.complete as CompleteCallback | undefined; +} +``` + +### `src/server/mcp.ts` + +The MCP server implementation was updated to use the compatibility layer throughout. + +#### Key Changes + +1. **Imports updated:** + +```typescript +// Before +import { z, ZodRawShape, AnyZodObject, ZodTypeAny } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; + +// After +import { AnySchema, AnyObjectSchema, ZodRawShapeCompat, ObjectOutput, normalizeObjectSchema, safeParseAsync, safeParse, isZ4Schema } from './zod-compat.js'; +import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; +``` + +2. **Schema normalization:** + +```typescript +// Before +inputSchema: tool.inputSchema + ? zodToJsonSchema(tool.inputSchema, {...}) + : EMPTY_OBJECT_JSON_SCHEMA + +// After +inputSchema: (() => { + const obj = normalizeObjectSchema(tool.inputSchema); + return obj + ? (toJsonSchemaCompat(obj, {...}) as Tool['inputSchema']) + : EMPTY_OBJECT_JSON_SCHEMA; +})() +``` + +The `normalizeObjectSchema` function handles both: + +- Already-constructed object schemas (v3 or v4) +- Raw shapes that need to be wrapped into object schemas + +3. **Parsing updated:** + +```typescript +// Before +const parseResult = await tool.inputSchema.safeParseAsync(request.params.arguments); + +// After +const inputObj = normalizeObjectSchema(tool.inputSchema) as AnyObjectSchema; +const parseResult = await safeParseAsync(inputObj, request.params.arguments); +``` + +4. **Completable detection:** + +```typescript +// Before +const field = prompt.argsSchema.shape[request.params.argument.name]; +if (!(field instanceof Completable)) { + return EMPTY_COMPLETION_RESULT; +} +const def: CompletableDef = field._def; +const suggestions = await def.complete(...); + +// After +const promptShape = getObjectShape(prompt.argsSchema); +const field = promptShape?.[request.params.argument.name]; +if (!isCompletable(field)) { + return EMPTY_COMPLETION_RESULT; +} +const completer = getCompleter(field); +const suggestions = await completer(...); +``` + +5. **Shape extraction:** + +```typescript +function getObjectShape(schema: AnyObjectSchema | undefined): Record | undefined { + if (!schema) return undefined; + + // Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape` + const rawShape = (schema as any).shape ?? (isZ4Schema(schema) ? (schema as any)._zod?.def?.shape : undefined); + + if (!rawShape) return undefined; + + if (typeof rawShape === 'function') { + try { + return rawShape(); + } catch { + return undefined; + } + } + + return rawShape as Record; +} +``` + +This handles the difference in how object shapes are accessed: + +- **v3**: `schema.shape` (direct property) +- **v4**: `schema._zod.def.shape` (nested, may be a function) + +### `src/shared/protocol.ts` + +The protocol layer handles request/response parsing and needed updates for schema handling. + +#### Key Changes + +1. **Type updates:** + +```typescript +// Before +sendRequest: >(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; + +// After +sendRequest: (request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; +``` + +2. **Request handler signatures:** + +```typescript +// Before +setRequestHandler< + T extends ZodObject<{ method: ZodLiteral }> +>( + requestSchema: T, + handler: (request: z.infer, extra: ...) => ... +): void { + const method = requestSchema.shape.method.value; + this._requestHandlers.set(method, (request, extra) => { + return Promise.resolve(handler(requestSchema.parse(request), extra)); + }); +} + +// After +setRequestHandler( + requestSchema: T, + handler: (request: SchemaOutput, extra: ...) => ... +): void { + const method = getMethodLiteral(requestSchema); + this._requestHandlers.set(method, (request, extra) => { + const parsed = parseWithCompat(requestSchema, request) as SchemaOutput; + return Promise.resolve(handler(parsed, extra)); + }); +} +``` + +3. **Helper functions:** + +```typescript +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; +} + +function getLiteralValue(schema: AnySchema): unknown { + const v4Def = isZ4Schema(schema) ? (schema as any)._zod?.def : undefined; + const legacyDef = (schema as any)._def; + + const candidates = [v4Def?.value, legacyDef?.value, Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, (schema as any).value]; + + for (const candidate of candidates) { + if (typeof candidate !== 'undefined') { + return candidate; + } + } + + return undefined; +} +``` + +These helpers extract literal values from schemas, handling differences in how v3 and v4 store this information. + +### `src/types.ts` + +The types file was updated to use Zod v4 by default: + +```typescript +// Before +import * as z from 'zod'; + +// After +import * as z from 'zod/v4'; +``` + +This means: + +- **New code** defaults to v4 (smaller bundle, better performance) +- **Existing code** using v3 continues to work via the compatibility layer +- The SDK's internal types use v4, but accept v3 schemas from users + +## Package.json Changes + +### Dependencies + +```json +{ + "dependencies": { + "zod": "^3.25 || ^4.0" + }, + "peerDependencies": { + "zod": "^3.25 || ^4.0" + } +} +``` + +Key points: + +- **Range supports both versions** - `^3.25 || ^4.0` allows either +- **Peer dependency** - users must provide their own Zod installation +- **Removed `zod-to-json-schema`** - now vendored for v3 support + +## Testing Strategy + +### Dual Test Suites + +We maintain **separate test files** for v3 and v4: + +- **v4 tests**: `src/server/mcp.test.ts`, `src/server/index.test.ts`, etc. +- **v3 tests**: `src/server/v3/mcp.v3.test.ts`, `src/server/v3/index.v3.test.ts`, etc. + +### Test File Pattern + +Each v3 test file: + +1. Imports from `'zod/v3'` explicitly +2. Uses v3-specific APIs (e.g., `.passthrough()` instead of `.looseObject()`) +3. Tests the same functionality as v4 tests +4. Verifies compatibility layer works correctly + +Example from `src/server/v3/mcp.v3.test.ts`: + +```typescript +import * as z from 'zod/v3'; + +// Uses v3-specific syntax +const RequestSchemaV3Base = z.object({ + method: z.string(), + params: z.optional(z.object({ _meta: z.optional(z.object({})) }).passthrough()) +}); +``` + +### Why Separate Tests? + +1. **API differences** - v3 and v4 have different APIs that need testing +2. **Type safety** - TypeScript can't always infer which version is being used +3. **Documentation** - Shows users how to use each version +4. **Regression prevention** - Ensures changes don't break either version + +## Migration Guide for Re-application + +When rebasing and re-applying these changes, follow this order: + +### 1. Create Compatibility Layer + +1. **Create `src/server/zod-compat.ts`** + - Copy the unified types + - Add runtime detection functions + - Add schema construction helpers + - Add unified parsing functions + +2. **Create `src/server/zod-json-schema-compat.ts`** + - Add JSON Schema conversion wrapper + - Map options between versions + - Handle both conversion paths + +### 2. Vendor zod-to-json-schema + +1. **Copy `zod-to-json-schema` to `src/_vendor/zod-to-json-schema/`** +2. **Update all imports** in vendored files to use `'zod/v3'` +3. **Update type references** to use v3 types +4. **Preserve LICENSE and README** + +### 3. Update Completable + +1. **Refactor `src/server/completable.ts`** + - Remove class inheritance + - Add symbol-based metadata + - Add detection helpers + - Add unwrap helper for backward compat + +### 4. Update Core Files + +1. **Update `src/server/mcp.ts`** + - Replace Zod imports with compat imports + - Update schema normalization calls + - Update parsing calls + - Update completable detection + - Add shape extraction helpers + +2. **Update `src/shared/protocol.ts`** + - Replace Zod types with compat types + - Update request handler signatures + - Add helper functions for method/literal extraction + - Update parsing calls + +3. **Update `src/types.ts`** + - Change import to `'zod/v4'` + - Update schema definitions to use v4 APIs + +### 5. Update Package.json + +1. **Update zod dependency** to `"^3.25 || ^4.0"` +2. **Add zod as peerDependency** +3. **Remove `zod-to-json-schema` dependency** + +### 6. Add Tests + +1. **Create v3 test files** in `src/server/v3/`, `src/client/v3/`, etc. +2. **Update existing tests** to use v4 imports +3. **Ensure both test suites pass** + +### 7. Update Examples + +1. **Update example files** to use `'zod/v4'` (or show both options) +2. **Add comments** showing v3 alternative where relevant + +## Key Challenges and Solutions + +### Challenge 1: Type System Differences + +**Problem**: v3 uses `ZodTypeAny`, v4 uses `$ZodType`. Type inference differs. + +**Solution**: Conditional types that check which version and use appropriate inference: + +```typescript +export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends z4.$ZodType ? z4.output : never; +``` + +### Challenge 2: API Differences + +**Problem**: Parsing, schema construction, and shape access all differ. + +**Solution**: Wrapper functions that detect version and call appropriate API: + +```typescript +export function safeParse(schema: S, data: unknown) { + if (isZ4Schema(schema)) { + return z4mini.safeParse(schema, data); + } + return schema.safeParse(data); +} +``` + +### Challenge 3: JSON Schema Conversion + +**Problem**: v3 needs external library, v4 has built-in method. + +**Solution**: Compatibility wrapper that routes to correct converter: + +```typescript +export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts) { + if (isZ4Schema(schema)) { + return z4mini.toJSONSchema(schema, {...}); + } + return zodToJsonSchema(schema, {...}); +} +``` + +### Challenge 4: Extensibility (Completable) + +**Problem**: Class inheritance doesn't work across versions. + +**Solution**: Symbol-based metadata that works with any schema: + +```typescript +export const COMPLETABLE_SYMBOL = Symbol.for('mcp.completable'); +export function completable(schema: T, complete: ...) { + Object.defineProperty(schema, COMPLETABLE_SYMBOL, {...}); + return schema; +} +``` + +### Challenge 5: Shape Access + +**Problem**: v3 has `schema.shape`, v4 has `schema._zod.def.shape` (may be function). + +**Solution**: Helper that checks both locations and handles functions: + +```typescript +function getObjectShape(schema: AnyObjectSchema) { + const rawShape = (schema as any).shape ?? (isZ4Schema(schema) ? (schema as any)._zod?.def?.shape : undefined); + if (typeof rawShape === 'function') { + return rawShape(); + } + return rawShape; +} +``` + +## Best Practices + +1. **Always use compat types** - Never import directly from `'zod'` in core code +2. **Normalize schemas early** - Use `normalizeObjectSchema()` when accepting schemas +3. **Use unified parsing** - Always use `safeParse()` / `safeParseAsync()` from compat +4. **Check version at runtime** - Use `isZ4Schema()` when needed, but prefer compat functions +5. **Don't mix versions** - Enforce single-version shapes +6. **Test both versions** - Maintain test coverage for v3 and v4 + +## Future Considerations + +1. **Zod v4 Classic** - Currently using Mini; may need Classic support later +2. **Deprecation path** - Eventually may deprecate v3 support +3. **Performance** - Monitor bundle size impact of dual support +4. **Type improvements** - May be able to improve type inference with newer TypeScript features + +## Summary + +The Zod v4 compatibility approach uses: + +- **Unified types** (`zod-compat.ts`) to abstract version differences +- **Runtime detection** (`isZ4Schema()`) to route to correct APIs +- **Compatibility wrappers** for parsing, JSON Schema conversion, etc. +- **Vendored dependencies** for v3-specific functionality +- **Symbol-based metadata** for extensibility without inheritance +- **Dual test suites** to ensure both versions work correctly + +This approach maintains full backward compatibility while adding v4 support, allowing users to migrate at their own pace. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5fd27f3ab..9f3e840e3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,5 +25,18 @@ export default tseslint.config( 'no-console': 'error' } }, + { + files: ['src/_vendor/**/*.ts'], + rules: { + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-wrapper-object-types': 'off', + 'no-fallthrough': 'off', + 'no-case-declarations': 'off', + 'no-useless-escape': 'off' + } + }, eslintConfigPrettier ); diff --git a/package-lock.json b/package-lock.json index 47005612b..c5cd38f98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,7 @@ "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" }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", @@ -50,11 +49,15 @@ "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 } } }, @@ -677,136 +680,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.20", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.20.tgz", - "integrity": "sha512-HDGiWh2tyRZa0M1ZnEIUCQro25gW/mN8ODByicQrbR1yHx4hT+IOpozCMi5TgBtUdklLwRI2mv14eNpftDluEw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/core": "^10.3.1", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.1.tgz", - "integrity": "sha512-hzGKIkfomGFPgxKmnKEKeA+uCYBqC+TKtRx5LgyHRCrF6S2MliwRIjp3sUaWwVzMp7ZXVs8elB0Tfe682Rpg4w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@inquirer/core/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -814,26 +687,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@mswjs/interceptors": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", - "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -882,37 +735,6 @@ "node": ">= 8" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -1437,15 +1259,6 @@ "@types/send": "*" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", @@ -1977,17 +1790,6 @@ } } }, - "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, - "optional": true, - "peer": 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", @@ -2168,34 +1970,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">= 12" - } - }, - "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, - "optional": true, - "peer": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2395,14 +2169,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "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, - "optional": true, - "peer": true - }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -2506,17 +2272,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, - "optional": true, - "peer": 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", @@ -3128,17 +2883,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", - "dev": true, - "optional": true, - "peer": 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", @@ -3230,18 +2974,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3290,15 +3022,6 @@ "node": ">= 0.4" } }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -3382,17 +3105,6 @@ "node": ">=0.10.0" } }, - "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, - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3405,15 +3117,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3628,113 +3331,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/msw": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.1.tgz", - "integrity": "sha512-arzsi9IZjjByiEw21gSUP82qHM8zkV69nNpWV6W4z72KiLvsDWoOp678ORV6cNfU/JGhlX0SsnD4oXo9gI6I2A==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.40.0", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.4", - "cookie": "^1.0.2", - "graphql": "^16.8.1", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.7.0", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^4.26.1", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/msw/node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/msw/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/msw/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "peer": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3827,15 +3423,6 @@ "node": ">= 0.8.0" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4092,17 +3679,6 @@ "node": ">=0.10.0" } }, - "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, - "optional": true, - "peer": 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", @@ -4130,15 +3706,6 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rettime": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", - "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -4456,45 +4023,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "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, - "optional": true, - "peer": 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, - "optional": true, - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4632,30 +4160,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.17.tgz", - "integrity": "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tldts-core": "^7.0.17" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.17", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.17.tgz", - "integrity": "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": 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", @@ -4676,21 +4180,6 @@ "node": ">=0.6" } }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -4800,18 +4289,6 @@ "node": ">= 0.8" } }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5089,25 +4566,6 @@ "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, - "optional": true, - "peer": 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", @@ -5134,48 +4592,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, - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - } - }, - "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, - "optional": true, - "peer": 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, - "optional": true, - "peer": 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", @@ -5188,38 +4604,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "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==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } } } } diff --git a/package.json b/package.json index aea1e0bf2..9155047c2 100644 --- a/package.json +++ b/package.json @@ -89,15 +89,18 @@ "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" }, "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": { diff --git a/src/_vendor/zod-to-json-schema/LICENSE b/src/_vendor/zod-to-json-schema/LICENSE new file mode 100644 index 000000000..a4690a1b6 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2020, Stefan Terdell + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/_vendor/zod-to-json-schema/Options.ts b/src/_vendor/zod-to-json-schema/Options.ts new file mode 100644 index 000000000..338254dda --- /dev/null +++ b/src/_vendor/zod-to-json-schema/Options.ts @@ -0,0 +1,80 @@ +import { ZodSchema, ZodTypeDef } from 'zod/v3'; +import { Refs, Seen } from './Refs.js'; +import { JsonSchema7Type } from './parseDef.js'; + +export type Targets = 'jsonSchema7' | 'jsonSchema2019-09' | 'openApi3'; + +export type DateStrategy = 'format:date-time' | 'format:date' | 'string' | 'integer'; + +export const ignoreOverride = Symbol('Let zodToJsonSchema decide on which parser to use'); + +export type Options = { + name: string | undefined; + $refStrategy: 'root' | 'relative' | 'none' | 'seen' | 'extract-to-root'; + basePath: string[]; + effectStrategy: 'input' | 'any'; + pipeStrategy: 'input' | 'output' | 'all'; + dateStrategy: DateStrategy | DateStrategy[]; + mapStrategy: 'entries' | 'record'; + removeAdditionalStrategy: 'passthrough' | 'strict'; + nullableStrategy: 'from-target' | 'property'; + target: Target; + strictUnions: boolean; + definitionPath: string; + definitions: Record; + errorMessages: boolean; + markdownDescription: boolean; + patternStrategy: 'escape' | 'preserve'; + applyRegexFlags: boolean; + emailStrategy: 'format:email' | 'format:idn-email' | 'pattern:zod'; + base64Strategy: 'format:binary' | 'contentEncoding:base64' | 'pattern:zod'; + nameStrategy: 'ref' | 'duplicate-ref' | 'title'; + override?: ( + def: ZodTypeDef, + refs: Refs, + seen: Seen | undefined, + forceResolution?: boolean, + ) => JsonSchema7Type | undefined | typeof ignoreOverride; + openaiStrictMode?: boolean; +}; + +const defaultOptions: Omit = { + name: undefined, + $refStrategy: 'root', + effectStrategy: 'input', + pipeStrategy: 'all', + dateStrategy: 'format:date-time', + mapStrategy: 'entries', + nullableStrategy: 'from-target', + removeAdditionalStrategy: 'passthrough', + definitionPath: 'definitions', + target: 'jsonSchema7', + strictUnions: false, + errorMessages: false, + markdownDescription: false, + patternStrategy: 'escape', + applyRegexFlags: false, + emailStrategy: 'format:email', + base64Strategy: 'contentEncoding:base64', + nameStrategy: 'ref', +}; + +export const getDefaultOptions = ( + options: Partial> | string | undefined, +) => { + // We need to add `definitions` here as we may mutate it + return ( + typeof options === 'string' ? + { + ...defaultOptions, + basePath: ['#'], + definitions: {}, + name: options, + } + : { + ...defaultOptions, + basePath: ['#'], + definitions: {}, + ...options, + }) as Options; +}; diff --git a/src/_vendor/zod-to-json-schema/README.md b/src/_vendor/zod-to-json-schema/README.md new file mode 100644 index 000000000..ffb351242 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/README.md @@ -0,0 +1,3 @@ +# Zod to Json Schema + +Vendored version of https://github.com/StefanTerdell/zod-to-json-schema that has been updated to generate JSON Schemas that are compatible with OpenAI's [strict mode](https://platform.openai.com/docs/guides/structured-outputs/supported-schemas) diff --git a/src/_vendor/zod-to-json-schema/Refs.ts b/src/_vendor/zod-to-json-schema/Refs.ts new file mode 100644 index 000000000..e8b290d12 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/Refs.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +import type { ZodTypeDef } from 'zod/v3'; +import { getDefaultOptions, Options, Targets } from './Options.js'; +import { JsonSchema7Type } from './parseDef.js'; +import { zodDef } from './util.js'; + +export type Refs = { + seen: Map; + /** + * Set of all the `$ref`s we created, e.g. `Set(['#/$defs/ui'])` + * this notable does not include any `definitions` that were + * explicitly given as an option. + */ + seenRefs: Set; + currentPath: string[]; + propertyPath: string[] | undefined; +} & Options; + +export type Seen = { + def: ZodTypeDef; + path: string[]; + jsonSchema: JsonSchema7Type | undefined; +}; + +export const getRefs = (options?: string | Partial>): Refs => { + const _options = getDefaultOptions(options); + const currentPath = _options.name !== undefined ? [..._options.basePath, _options.definitionPath, _options.name] : _options.basePath; + return { + ..._options, + currentPath: currentPath, + propertyPath: undefined, + seenRefs: new Set(), + seen: new Map( + Object.entries(_options.definitions).map(([name, def]) => [ + zodDef(def), + { + def: zodDef(def), + path: [..._options.basePath, _options.definitionPath, name], + // Resolution of references will be forced even though seen, so it's ok that the schema is undefined here for now. + jsonSchema: undefined + } + ]) + ) + }; +}; diff --git a/src/_vendor/zod-to-json-schema/errorMessages.ts b/src/_vendor/zod-to-json-schema/errorMessages.ts new file mode 100644 index 000000000..8ce868e2e --- /dev/null +++ b/src/_vendor/zod-to-json-schema/errorMessages.ts @@ -0,0 +1,31 @@ +import { JsonSchema7TypeUnion } from './parseDef.js'; +import { Refs } from './Refs.js'; + +export type ErrorMessages = Partial< + Omit<{ [key in keyof T]: string }, OmitProperties | 'type' | 'errorMessages'> +>; + +export function addErrorMessage }>( + res: T, + key: keyof T, + errorMessage: string | undefined, + refs: Refs, +) { + if (!refs?.errorMessages) return; + if (errorMessage) { + res.errorMessage = { + ...res.errorMessage, + [key]: errorMessage, + }; + } +} + +export function setResponseValueAndErrors< + Json7Type extends JsonSchema7TypeUnion & { + errorMessage?: ErrorMessages; + }, + Key extends keyof Omit, +>(res: Json7Type, key: Key, value: Json7Type[Key], errorMessage: string | undefined, refs: Refs) { + res[key] = value; + addErrorMessage(res, key, errorMessage, refs); +} diff --git a/src/_vendor/zod-to-json-schema/index.ts b/src/_vendor/zod-to-json-schema/index.ts new file mode 100644 index 000000000..12847e27f --- /dev/null +++ b/src/_vendor/zod-to-json-schema/index.ts @@ -0,0 +1,38 @@ +// @ts-nocheck +export * from './Options.js'; +export * from './Refs.js'; +export * from './errorMessages.js'; +export * from './parseDef.js'; +export * from './parsers/any.js'; +export * from './parsers/array.js'; +export * from './parsers/bigint.js'; +export * from './parsers/boolean.js'; +export * from './parsers/branded.js'; +export * from './parsers/catch.js'; +export * from './parsers/date.js'; +export * from './parsers/default.js'; +export * from './parsers/effects.js'; +export * from './parsers/enum.js'; +export * from './parsers/intersection.js'; +export * from './parsers/literal.js'; +export * from './parsers/map.js'; +export * from './parsers/nativeEnum.js'; +export * from './parsers/never.js'; +export * from './parsers/null.js'; +export * from './parsers/nullable.js'; +export * from './parsers/number.js'; +export * from './parsers/object.js'; +export * from './parsers/optional.js'; +export * from './parsers/pipeline.js'; +export * from './parsers/promise.js'; +export * from './parsers/readonly.js'; +export * from './parsers/record.js'; +export * from './parsers/set.js'; +export * from './parsers/string.js'; +export * from './parsers/tuple.js'; +export * from './parsers/undefined.js'; +export * from './parsers/union.js'; +export * from './parsers/unknown.js'; +export * from './zodToJsonSchema.js'; +import { zodToJsonSchema } from './zodToJsonSchema.js'; +export default zodToJsonSchema; diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts new file mode 100644 index 000000000..113b68555 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parseDef.ts @@ -0,0 +1,258 @@ +import { ZodFirstPartyTypeKind, ZodTypeDef } from 'zod/v3'; +import { JsonSchema7AnyType, parseAnyDef } from './parsers/any.js'; +import { JsonSchema7ArrayType, parseArrayDef } from './parsers/array.js'; +import { JsonSchema7BigintType, parseBigintDef } from './parsers/bigint.js'; +import { JsonSchema7BooleanType, parseBooleanDef } from './parsers/boolean.js'; +import { parseBrandedDef } from './parsers/branded.js'; +import { parseCatchDef } from './parsers/catch.js'; +import { JsonSchema7DateType, parseDateDef } from './parsers/date.js'; +import { parseDefaultDef } from './parsers/default.js'; +import { parseEffectsDef } from './parsers/effects.js'; +import { JsonSchema7EnumType, parseEnumDef } from './parsers/enum.js'; +import { JsonSchema7AllOfType, parseIntersectionDef } from './parsers/intersection.js'; +import { JsonSchema7LiteralType, parseLiteralDef } from './parsers/literal.js'; +import { JsonSchema7MapType, parseMapDef } from './parsers/map.js'; +import { JsonSchema7NativeEnumType, parseNativeEnumDef } from './parsers/nativeEnum.js'; +import { JsonSchema7NeverType, parseNeverDef } from './parsers/never.js'; +import { JsonSchema7NullType, parseNullDef } from './parsers/null.js'; +import { JsonSchema7NullableType, parseNullableDef } from './parsers/nullable.js'; +import { JsonSchema7NumberType, parseNumberDef } from './parsers/number.js'; +import { JsonSchema7ObjectType, parseObjectDef } from './parsers/object.js'; +import { parseOptionalDef } from './parsers/optional.js'; +import { parsePipelineDef } from './parsers/pipeline.js'; +import { parsePromiseDef } from './parsers/promise.js'; +import { JsonSchema7RecordType, parseRecordDef } from './parsers/record.js'; +import { JsonSchema7SetType, parseSetDef } from './parsers/set.js'; +import { JsonSchema7StringType, parseStringDef } from './parsers/string.js'; +import { JsonSchema7TupleType, parseTupleDef } from './parsers/tuple.js'; +import { JsonSchema7UndefinedType, parseUndefinedDef } from './parsers/undefined.js'; +import { JsonSchema7UnionType, parseUnionDef } from './parsers/union.js'; +import { JsonSchema7UnknownType, parseUnknownDef } from './parsers/unknown.js'; +import { Refs, Seen } from './Refs.js'; +import { parseReadonlyDef } from './parsers/readonly.js'; +import { ignoreOverride } from './Options.js'; + +type JsonSchema7RefType = { $ref: string }; +type JsonSchema7Meta = { + title?: string; + default?: any; + description?: string; + markdownDescription?: string; +}; + +export type JsonSchema7TypeUnion = + | JsonSchema7StringType + | JsonSchema7ArrayType + | JsonSchema7NumberType + | JsonSchema7BigintType + | JsonSchema7BooleanType + | JsonSchema7DateType + | JsonSchema7EnumType + | JsonSchema7LiteralType + | JsonSchema7NativeEnumType + | JsonSchema7NullType + | JsonSchema7NumberType + | JsonSchema7ObjectType + | JsonSchema7RecordType + | JsonSchema7TupleType + | JsonSchema7UnionType + | JsonSchema7UndefinedType + | JsonSchema7RefType + | JsonSchema7NeverType + | JsonSchema7MapType + | JsonSchema7AnyType + | JsonSchema7NullableType + | JsonSchema7AllOfType + | JsonSchema7UnknownType + | JsonSchema7SetType; + +export type JsonSchema7Type = JsonSchema7TypeUnion & JsonSchema7Meta; + +export function parseDef( + def: ZodTypeDef, + refs: Refs, + forceResolution = false, // Forces a new schema to be instantiated even though its def has been seen. Used for improving refs in definitions. See https://github.com/StefanTerdell/zod-to-json-schema/pull/61. +): JsonSchema7Type | undefined { + const seenItem = refs.seen.get(def); + + if (refs.override) { + const overrideResult = refs.override?.(def, refs, seenItem, forceResolution); + + if (overrideResult !== ignoreOverride) { + return overrideResult; + } + } + + if (seenItem && !forceResolution) { + const seenSchema = get$ref(seenItem, refs); + + if (seenSchema !== undefined) { + if ('$ref' in seenSchema) { + refs.seenRefs.add(seenSchema.$ref); + } + + return seenSchema; + } + } + + const newItem: Seen = { def, path: refs.currentPath, jsonSchema: undefined }; + + refs.seen.set(def, newItem); + + const jsonSchema = selectParser(def, (def as any).typeName, refs, forceResolution); + + if (jsonSchema) { + addMeta(def, refs, jsonSchema); + } + + newItem.jsonSchema = jsonSchema; + + return jsonSchema; +} + +const get$ref = ( + item: Seen, + refs: Refs, +): + | { + $ref: string; + } + | {} + | undefined => { + switch (refs.$refStrategy) { + case 'root': + return { $ref: item.path.join('/') }; + // this case is needed as OpenAI strict mode doesn't support top-level `$ref`s, i.e. + // the top-level schema *must* be `{"type": "object", "properties": {...}}` but if we ever + // need to define a `$ref`, relative `$ref`s aren't supported, so we need to extract + // the schema to `#/definitions/` and reference that. + // + // e.g. if we need to reference a schema at + // `["#","definitions","contactPerson","properties","person1","properties","name"]` + // then we'll extract it out to `contactPerson_properties_person1_properties_name` + case 'extract-to-root': + const name = item.path.slice(refs.basePath.length + 1).join('_'); + + // we don't need to extract the root schema in this case, as it's already + // been added to the definitions + if (name !== refs.name && refs.nameStrategy === 'duplicate-ref') { + refs.definitions[name] = item.def; + } + + return { $ref: [...refs.basePath, refs.definitionPath, name].join('/') }; + case 'relative': + return { $ref: getRelativePath(refs.currentPath, item.path) }; + case 'none': + case 'seen': { + if ( + item.path.length < refs.currentPath.length && + item.path.every((value, index) => refs.currentPath[index] === value) + ) { + console.warn(`Recursive reference detected at ${refs.currentPath.join('/')}! Defaulting to any`); + + return {}; + } + + return refs.$refStrategy === 'seen' ? {} : undefined; + } + } +}; + +const getRelativePath = (pathA: string[], pathB: string[]) => { + let i = 0; + for (; i < pathA.length && i < pathB.length; i++) { + if (pathA[i] !== pathB[i]) break; + } + return [(pathA.length - i).toString(), ...pathB.slice(i)].join('/'); +}; + +const selectParser = ( + def: any, + typeName: ZodFirstPartyTypeKind, + refs: Refs, + forceResolution: boolean, +): JsonSchema7Type | undefined => { + switch (typeName) { + case ZodFirstPartyTypeKind.ZodString: + return parseStringDef(def, refs); + case ZodFirstPartyTypeKind.ZodNumber: + return parseNumberDef(def, refs); + case ZodFirstPartyTypeKind.ZodObject: + return parseObjectDef(def, refs); + case ZodFirstPartyTypeKind.ZodBigInt: + return parseBigintDef(def, refs); + case ZodFirstPartyTypeKind.ZodBoolean: + return parseBooleanDef(); + case ZodFirstPartyTypeKind.ZodDate: + return parseDateDef(def, refs); + case ZodFirstPartyTypeKind.ZodUndefined: + return parseUndefinedDef(); + case ZodFirstPartyTypeKind.ZodNull: + return parseNullDef(refs); + case ZodFirstPartyTypeKind.ZodArray: + return parseArrayDef(def, refs); + case ZodFirstPartyTypeKind.ZodUnion: + case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: + return parseUnionDef(def, refs); + case ZodFirstPartyTypeKind.ZodIntersection: + return parseIntersectionDef(def, refs); + case ZodFirstPartyTypeKind.ZodTuple: + return parseTupleDef(def, refs); + case ZodFirstPartyTypeKind.ZodRecord: + return parseRecordDef(def, refs); + case ZodFirstPartyTypeKind.ZodLiteral: + return parseLiteralDef(def, refs); + case ZodFirstPartyTypeKind.ZodEnum: + return parseEnumDef(def); + case ZodFirstPartyTypeKind.ZodNativeEnum: + return parseNativeEnumDef(def); + case ZodFirstPartyTypeKind.ZodNullable: + return parseNullableDef(def, refs); + case ZodFirstPartyTypeKind.ZodOptional: + return parseOptionalDef(def, refs); + case ZodFirstPartyTypeKind.ZodMap: + return parseMapDef(def, refs); + case ZodFirstPartyTypeKind.ZodSet: + return parseSetDef(def, refs); + case ZodFirstPartyTypeKind.ZodLazy: + return parseDef(def.getter()._def, refs); + case ZodFirstPartyTypeKind.ZodPromise: + return parsePromiseDef(def, refs); + case ZodFirstPartyTypeKind.ZodNaN: + case ZodFirstPartyTypeKind.ZodNever: + return parseNeverDef(); + case ZodFirstPartyTypeKind.ZodEffects: + return parseEffectsDef(def, refs, forceResolution); + case ZodFirstPartyTypeKind.ZodAny: + return parseAnyDef(); + case ZodFirstPartyTypeKind.ZodUnknown: + return parseUnknownDef(); + case ZodFirstPartyTypeKind.ZodDefault: + return parseDefaultDef(def, refs); + case ZodFirstPartyTypeKind.ZodBranded: + return parseBrandedDef(def, refs); + case ZodFirstPartyTypeKind.ZodReadonly: + return parseReadonlyDef(def, refs); + case ZodFirstPartyTypeKind.ZodCatch: + return parseCatchDef(def, refs); + case ZodFirstPartyTypeKind.ZodPipeline: + return parsePipelineDef(def, refs); + case ZodFirstPartyTypeKind.ZodFunction: + case ZodFirstPartyTypeKind.ZodVoid: + case ZodFirstPartyTypeKind.ZodSymbol: + return undefined; + default: + return ((_: never) => undefined)(typeName); + } +}; + +const addMeta = (def: ZodTypeDef, refs: Refs, jsonSchema: JsonSchema7Type): JsonSchema7Type => { + if (def.description) { + jsonSchema.description = def.description; + + if (refs.markdownDescription) { + jsonSchema.markdownDescription = def.description; + } + } + return jsonSchema; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/any.ts b/src/_vendor/zod-to-json-schema/parsers/any.ts new file mode 100644 index 000000000..68c2921da --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/any.ts @@ -0,0 +1,5 @@ +export type JsonSchema7AnyType = {}; + +export function parseAnyDef(): JsonSchema7AnyType { + return {}; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/array.ts b/src/_vendor/zod-to-json-schema/parsers/array.ts new file mode 100644 index 000000000..97791d77a --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/array.ts @@ -0,0 +1,36 @@ +import { ZodArrayDef, ZodFirstPartyTypeKind } from 'zod/v3'; +import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export type JsonSchema7ArrayType = { + type: 'array'; + items?: JsonSchema7Type | undefined; + minItems?: number; + maxItems?: number; + errorMessages?: ErrorMessages; +}; + +export function parseArrayDef(def: ZodArrayDef, refs: Refs) { + const res: JsonSchema7ArrayType = { + type: 'array', + }; + if (def.type?._def?.typeName !== ZodFirstPartyTypeKind.ZodAny) { + res.items = parseDef(def.type._def, { + ...refs, + currentPath: [...refs.currentPath, 'items'], + }); + } + + if (def.minLength) { + setResponseValueAndErrors(res, 'minItems', def.minLength.value, def.minLength.message, refs); + } + if (def.maxLength) { + setResponseValueAndErrors(res, 'maxItems', def.maxLength.value, def.maxLength.message, refs); + } + if (def.exactLength) { + setResponseValueAndErrors(res, 'minItems', def.exactLength.value, def.exactLength.message, refs); + setResponseValueAndErrors(res, 'maxItems', def.exactLength.value, def.exactLength.message, refs); + } + return res; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/bigint.ts b/src/_vendor/zod-to-json-schema/parsers/bigint.ts new file mode 100644 index 000000000..59385d82b --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/bigint.ts @@ -0,0 +1,60 @@ +import { ZodBigIntDef } from 'zod/v3'; +import { Refs } from '../Refs.js'; +import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; + +export type JsonSchema7BigintType = { + type: 'integer'; + format: 'int64'; + minimum?: BigInt; + exclusiveMinimum?: BigInt; + maximum?: BigInt; + exclusiveMaximum?: BigInt; + multipleOf?: BigInt; + errorMessage?: ErrorMessages; +}; + +export function parseBigintDef(def: ZodBigIntDef, refs: Refs): JsonSchema7BigintType { + const res: JsonSchema7BigintType = { + type: 'integer', + format: 'int64', + }; + + if (!def.checks) return res; + + for (const check of def.checks) { + switch (check.kind) { + case 'min': + if (refs.target === 'jsonSchema7') { + if (check.inclusive) { + setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); + } else { + setResponseValueAndErrors(res, 'exclusiveMinimum', check.value, check.message, refs); + } + } else { + if (!check.inclusive) { + res.exclusiveMinimum = true as any; + } + setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); + } + break; + case 'max': + if (refs.target === 'jsonSchema7') { + if (check.inclusive) { + setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); + } else { + setResponseValueAndErrors(res, 'exclusiveMaximum', check.value, check.message, refs); + } + } else { + if (!check.inclusive) { + res.exclusiveMaximum = true as any; + } + setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); + } + break; + case 'multipleOf': + setResponseValueAndErrors(res, 'multipleOf', check.value, check.message, refs); + break; + } + } + return res; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/boolean.ts b/src/_vendor/zod-to-json-schema/parsers/boolean.ts new file mode 100644 index 000000000..715e41acc --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/boolean.ts @@ -0,0 +1,9 @@ +export type JsonSchema7BooleanType = { + type: 'boolean'; +}; + +export function parseBooleanDef(): JsonSchema7BooleanType { + return { + type: 'boolean', + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/branded.ts b/src/_vendor/zod-to-json-schema/parsers/branded.ts new file mode 100644 index 000000000..a09075ac2 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/branded.ts @@ -0,0 +1,7 @@ +import { ZodBrandedDef } from 'zod/v3'; +import { parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export function parseBrandedDef(_def: ZodBrandedDef, refs: Refs) { + return parseDef(_def.type._def, refs); +} diff --git a/src/_vendor/zod-to-json-schema/parsers/catch.ts b/src/_vendor/zod-to-json-schema/parsers/catch.ts new file mode 100644 index 000000000..923df6c62 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/catch.ts @@ -0,0 +1,7 @@ +import { ZodCatchDef } from 'zod/v3'; +import { parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export const parseCatchDef = (def: ZodCatchDef, refs: Refs) => { + return parseDef(def.innerType._def, refs); +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/date.ts b/src/_vendor/zod-to-json-schema/parsers/date.ts new file mode 100644 index 000000000..2286b939d --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/date.ts @@ -0,0 +1,83 @@ +import { ZodDateDef } from 'zod/v3'; +import { Refs } from '../Refs.js'; +import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; +import { JsonSchema7NumberType } from './number.js'; +import { DateStrategy } from '../Options.js'; + +export type JsonSchema7DateType = + | { + type: 'integer' | 'string'; + format: 'unix-time' | 'date-time' | 'date'; + minimum?: number; + maximum?: number; + errorMessage?: ErrorMessages; + } + | { + anyOf: JsonSchema7DateType[]; + }; + +export function parseDateDef( + def: ZodDateDef, + refs: Refs, + overrideDateStrategy?: DateStrategy, +): JsonSchema7DateType { + const strategy = overrideDateStrategy ?? refs.dateStrategy; + + if (Array.isArray(strategy)) { + return { + anyOf: strategy.map((item, i) => parseDateDef(def, refs, item)), + }; + } + + switch (strategy) { + case 'string': + case 'format:date-time': + return { + type: 'string', + format: 'date-time', + }; + case 'format:date': + return { + type: 'string', + format: 'date', + }; + case 'integer': + return integerDateParser(def, refs); + } +} + +const integerDateParser = (def: ZodDateDef, refs: Refs) => { + const res: JsonSchema7DateType = { + type: 'integer', + format: 'unix-time', + }; + + if (refs.target === 'openApi3') { + return res; + } + + for (const check of def.checks) { + switch (check.kind) { + case 'min': + setResponseValueAndErrors( + res, + 'minimum', + check.value, // This is in milliseconds + check.message, + refs, + ); + break; + case 'max': + setResponseValueAndErrors( + res, + 'maximum', + check.value, // This is in milliseconds + check.message, + refs, + ); + break; + } + } + + return res; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/default.ts b/src/_vendor/zod-to-json-schema/parsers/default.ts new file mode 100644 index 000000000..cca964314 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/default.ts @@ -0,0 +1,10 @@ +import { ZodDefaultDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export function parseDefaultDef(_def: ZodDefaultDef, refs: Refs): JsonSchema7Type & { default: any } { + return { + ...parseDef(_def.innerType._def, refs), + default: _def.defaultValue(), + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/effects.ts b/src/_vendor/zod-to-json-schema/parsers/effects.ts new file mode 100644 index 000000000..2b834f432 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/effects.ts @@ -0,0 +1,11 @@ +import { ZodEffectsDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export function parseEffectsDef( + _def: ZodEffectsDef, + refs: Refs, + forceResolution: boolean, +): JsonSchema7Type | undefined { + return refs.effectStrategy === 'input' ? parseDef(_def.schema._def, refs, forceResolution) : {}; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/enum.ts b/src/_vendor/zod-to-json-schema/parsers/enum.ts new file mode 100644 index 000000000..ed459f33f --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/enum.ts @@ -0,0 +1,13 @@ +import { ZodEnumDef } from 'zod/v3'; + +export type JsonSchema7EnumType = { + type: 'string'; + enum: string[]; +}; + +export function parseEnumDef(def: ZodEnumDef): JsonSchema7EnumType { + return { + type: 'string', + enum: [...def.values], + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/intersection.ts b/src/_vendor/zod-to-json-schema/parsers/intersection.ts new file mode 100644 index 000000000..1ab5ce1c4 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/intersection.ts @@ -0,0 +1,64 @@ +import { ZodIntersectionDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; +import { JsonSchema7StringType } from './string.js'; + +export type JsonSchema7AllOfType = { + allOf: JsonSchema7Type[]; + unevaluatedProperties?: boolean; +}; + +const isJsonSchema7AllOfType = ( + type: JsonSchema7Type | JsonSchema7StringType, +): type is JsonSchema7AllOfType => { + if ('type' in type && type.type === 'string') return false; + return 'allOf' in type; +}; + +export function parseIntersectionDef( + def: ZodIntersectionDef, + refs: Refs, +): JsonSchema7AllOfType | JsonSchema7Type | undefined { + const allOf = [ + parseDef(def.left._def, { + ...refs, + currentPath: [...refs.currentPath, 'allOf', '0'], + }), + parseDef(def.right._def, { + ...refs, + currentPath: [...refs.currentPath, 'allOf', '1'], + }), + ].filter((x): x is JsonSchema7Type => !!x); + + let unevaluatedProperties: Pick | undefined = + refs.target === 'jsonSchema2019-09' ? { unevaluatedProperties: false } : undefined; + + const mergedAllOf: JsonSchema7Type[] = []; + // If either of the schemas is an allOf, merge them into a single allOf + allOf.forEach((schema) => { + if (isJsonSchema7AllOfType(schema)) { + mergedAllOf.push(...schema.allOf); + if (schema.unevaluatedProperties === undefined) { + // If one of the schemas has no unevaluatedProperties set, + // the merged schema should also have no unevaluatedProperties set + unevaluatedProperties = undefined; + } + } else { + let nestedSchema: JsonSchema7Type = schema; + if ('additionalProperties' in schema && schema.additionalProperties === false) { + const { additionalProperties, ...rest } = schema; + nestedSchema = rest; + } else { + // As soon as one of the schemas has additionalProperties set not to false, we allow unevaluatedProperties + unevaluatedProperties = undefined; + } + mergedAllOf.push(nestedSchema); + } + }); + return mergedAllOf.length ? + { + allOf: mergedAllOf, + ...unevaluatedProperties, + } + : undefined; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/literal.ts b/src/_vendor/zod-to-json-schema/parsers/literal.ts new file mode 100644 index 000000000..5452fbcf9 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/literal.ts @@ -0,0 +1,37 @@ +import { ZodLiteralDef } from 'zod/v3'; +import { Refs } from '../Refs.js'; + +export type JsonSchema7LiteralType = + | { + type: 'string' | 'number' | 'integer' | 'boolean'; + const: string | number | boolean; + } + | { + type: 'object' | 'array'; + }; + +export function parseLiteralDef(def: ZodLiteralDef, refs: Refs): JsonSchema7LiteralType { + const parsedType = typeof def.value; + if ( + parsedType !== 'bigint' && + parsedType !== 'number' && + parsedType !== 'boolean' && + parsedType !== 'string' + ) { + return { + type: Array.isArray(def.value) ? 'array' : 'object', + }; + } + + if (refs.target === 'openApi3') { + return { + type: parsedType === 'bigint' ? 'integer' : parsedType, + enum: [def.value], + } as any; + } + + return { + type: parsedType === 'bigint' ? 'integer' : parsedType, + const: def.value, + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/map.ts b/src/_vendor/zod-to-json-schema/parsers/map.ts new file mode 100644 index 000000000..81c50d21a --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/map.ts @@ -0,0 +1,42 @@ +import { ZodMapDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; +import { JsonSchema7RecordType, parseRecordDef } from './record.js'; + +export type JsonSchema7MapType = { + type: 'array'; + maxItems: 125; + items: { + type: 'array'; + items: [JsonSchema7Type, JsonSchema7Type]; + minItems: 2; + maxItems: 2; + }; +}; + +export function parseMapDef(def: ZodMapDef, refs: Refs): JsonSchema7MapType | JsonSchema7RecordType { + if (refs.mapStrategy === 'record') { + return parseRecordDef(def, refs); + } + + const keys = + parseDef(def.keyType._def, { + ...refs, + currentPath: [...refs.currentPath, 'items', 'items', '0'], + }) || {}; + const values = + parseDef(def.valueType._def, { + ...refs, + currentPath: [...refs.currentPath, 'items', 'items', '1'], + }) || {}; + return { + type: 'array', + maxItems: 125, + items: { + type: 'array', + items: [keys, values], + minItems: 2, + maxItems: 2, + }, + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts b/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts new file mode 100644 index 000000000..e3539883b --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts @@ -0,0 +1,27 @@ +import { ZodNativeEnumDef } from 'zod/v3'; + +export type JsonSchema7NativeEnumType = { + type: 'string' | 'number' | ['string', 'number']; + enum: (string | number)[]; +}; + +export function parseNativeEnumDef(def: ZodNativeEnumDef): JsonSchema7NativeEnumType { + const object = def.values; + const actualKeys = Object.keys(def.values).filter((key: string) => { + return typeof object[object[key]!] !== 'number'; + }); + + const actualValues = actualKeys.map((key: string) => object[key]!); + + const parsedTypes = Array.from(new Set(actualValues.map((values: string | number) => typeof values))); + + return { + type: + parsedTypes.length === 1 ? + parsedTypes[0] === 'string' ? + 'string' + : 'number' + : ['string', 'number'], + enum: actualValues, + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/never.ts b/src/_vendor/zod-to-json-schema/parsers/never.ts new file mode 100644 index 000000000..a5c7383d7 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/never.ts @@ -0,0 +1,9 @@ +export type JsonSchema7NeverType = { + not: {}; +}; + +export function parseNeverDef(): JsonSchema7NeverType { + return { + not: {}, + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/null.ts b/src/_vendor/zod-to-json-schema/parsers/null.ts new file mode 100644 index 000000000..4da424be9 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/null.ts @@ -0,0 +1,16 @@ +import { Refs } from '../Refs.js'; + +export type JsonSchema7NullType = { + type: 'null'; +}; + +export function parseNullDef(refs: Refs): JsonSchema7NullType { + return refs.target === 'openApi3' ? + ({ + enum: ['null'], + nullable: true, + } as any) + : { + type: 'null', + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/nullable.ts b/src/_vendor/zod-to-json-schema/parsers/nullable.ts new file mode 100644 index 000000000..2838031b2 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/nullable.ts @@ -0,0 +1,49 @@ +import { ZodNullableDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; +import { JsonSchema7NullType } from './null.js'; +import { primitiveMappings } from './union.js'; + +export type JsonSchema7NullableType = + | { + anyOf: [JsonSchema7Type, JsonSchema7NullType]; + } + | { + type: [string, 'null']; + }; + +export function parseNullableDef(def: ZodNullableDef, refs: Refs): JsonSchema7NullableType | undefined { + if ( + ['ZodString', 'ZodNumber', 'ZodBigInt', 'ZodBoolean', 'ZodNull'].includes(def.innerType._def.typeName) && + (!def.innerType._def.checks || !def.innerType._def.checks.length) + ) { + if (refs.target === 'openApi3' || refs.nullableStrategy === 'property') { + return { + type: primitiveMappings[def.innerType._def.typeName as keyof typeof primitiveMappings], + nullable: true, + } as any; + } + + return { + type: [primitiveMappings[def.innerType._def.typeName as keyof typeof primitiveMappings], 'null'], + }; + } + + if (refs.target === 'openApi3') { + const base = parseDef(def.innerType._def, { + ...refs, + currentPath: [...refs.currentPath], + }); + + if (base && '$ref' in base) return { allOf: [base], nullable: true } as any; + + return base && ({ ...base, nullable: true } as any); + } + + const base = parseDef(def.innerType._def, { + ...refs, + currentPath: [...refs.currentPath, 'anyOf', '0'], + }); + + return base && { anyOf: [base, { type: 'null' }] }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/number.ts b/src/_vendor/zod-to-json-schema/parsers/number.ts new file mode 100644 index 000000000..0536c70a7 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/number.ts @@ -0,0 +1,62 @@ +import { ZodNumberDef } from 'zod/v3'; +import { addErrorMessage, ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; +import { Refs } from '../Refs.js'; + +export type JsonSchema7NumberType = { + type: 'number' | 'integer'; + minimum?: number; + exclusiveMinimum?: number; + maximum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + errorMessage?: ErrorMessages; +}; + +export function parseNumberDef(def: ZodNumberDef, refs: Refs): JsonSchema7NumberType { + const res: JsonSchema7NumberType = { + type: 'number', + }; + + if (!def.checks) return res; + + for (const check of def.checks) { + switch (check.kind) { + case 'int': + res.type = 'integer'; + addErrorMessage(res, 'type', check.message, refs); + break; + case 'min': + if (refs.target === 'jsonSchema7') { + if (check.inclusive) { + setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); + } else { + setResponseValueAndErrors(res, 'exclusiveMinimum', check.value, check.message, refs); + } + } else { + if (!check.inclusive) { + res.exclusiveMinimum = true as any; + } + setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); + } + break; + case 'max': + if (refs.target === 'jsonSchema7') { + if (check.inclusive) { + setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); + } else { + setResponseValueAndErrors(res, 'exclusiveMaximum', check.value, check.message, refs); + } + } else { + if (!check.inclusive) { + res.exclusiveMaximum = true as any; + } + setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); + } + break; + case 'multipleOf': + setResponseValueAndErrors(res, 'multipleOf', check.value, check.message, refs); + break; + } + } + return res; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/object.ts b/src/_vendor/zod-to-json-schema/parsers/object.ts new file mode 100644 index 000000000..7de05a245 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/object.ts @@ -0,0 +1,76 @@ +import { ZodObjectDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +function decideAdditionalProperties(def: ZodObjectDef, refs: Refs) { + if (refs.removeAdditionalStrategy === 'strict') { + return def.catchall._def.typeName === 'ZodNever' ? + def.unknownKeys !== 'strict' + : parseDef(def.catchall._def, { + ...refs, + currentPath: [...refs.currentPath, 'additionalProperties'], + }) ?? true; + } else { + return def.catchall._def.typeName === 'ZodNever' ? + def.unknownKeys === 'passthrough' + : parseDef(def.catchall._def, { + ...refs, + currentPath: [...refs.currentPath, 'additionalProperties'], + }) ?? true; + } +} + +export type JsonSchema7ObjectType = { + type: 'object'; + properties: Record; + additionalProperties: boolean | JsonSchema7Type; + required?: string[]; +}; + +export function parseObjectDef(def: ZodObjectDef, refs: Refs) { + const result: JsonSchema7ObjectType = { + type: 'object', + ...Object.entries(def.shape()).reduce( + ( + acc: { + properties: Record; + required: string[]; + }, + [propName, propDef], + ) => { + if (propDef === undefined || propDef._def === undefined) return acc; + const propertyPath = [...refs.currentPath, 'properties', propName]; + const parsedDef = parseDef(propDef._def, { + ...refs, + currentPath: propertyPath, + propertyPath, + }); + if (parsedDef === undefined) return acc; + if ( + refs.openaiStrictMode && + propDef.isOptional() && + !propDef.isNullable() && + typeof propDef._def?.defaultValue === 'undefined' + ) { + throw new Error( + `Zod field at \`${propertyPath.join( + '/', + )}\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required`, + ); + } + return { + properties: { + ...acc.properties, + [propName]: parsedDef, + }, + required: + propDef.isOptional() && !refs.openaiStrictMode ? acc.required : [...acc.required, propName], + }; + }, + { properties: {}, required: [] }, + ), + additionalProperties: decideAdditionalProperties(def, refs), + }; + if (!result.required!.length) delete result.required; + return result; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/optional.ts b/src/_vendor/zod-to-json-schema/parsers/optional.ts new file mode 100644 index 000000000..b68f606a0 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/optional.ts @@ -0,0 +1,28 @@ +import { ZodOptionalDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export const parseOptionalDef = (def: ZodOptionalDef, refs: Refs): JsonSchema7Type | undefined => { + if ( + refs.propertyPath && + refs.currentPath.slice(0, refs.propertyPath.length).toString() === refs.propertyPath.toString() + ) { + return parseDef(def.innerType._def, { ...refs, currentPath: refs.currentPath }); + } + + const innerSchema = parseDef(def.innerType._def, { + ...refs, + currentPath: [...refs.currentPath, 'anyOf', '1'], + }); + + return innerSchema ? + { + anyOf: [ + { + not: {}, + }, + innerSchema, + ], + } + : {}; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/pipeline.ts b/src/_vendor/zod-to-json-schema/parsers/pipeline.ts new file mode 100644 index 000000000..8f4b1f4bd --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/pipeline.ts @@ -0,0 +1,28 @@ +import { ZodPipelineDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; +import { JsonSchema7AllOfType } from './intersection.js'; + +export const parsePipelineDef = ( + def: ZodPipelineDef, + refs: Refs, +): JsonSchema7AllOfType | JsonSchema7Type | undefined => { + if (refs.pipeStrategy === 'input') { + return parseDef(def.in._def, refs); + } else if (refs.pipeStrategy === 'output') { + return parseDef(def.out._def, refs); + } + + const a = parseDef(def.in._def, { + ...refs, + currentPath: [...refs.currentPath, 'allOf', '0'], + }); + const b = parseDef(def.out._def, { + ...refs, + currentPath: [...refs.currentPath, 'allOf', a ? '1' : '0'], + }); + + return { + allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined), + }; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/promise.ts b/src/_vendor/zod-to-json-schema/parsers/promise.ts new file mode 100644 index 000000000..26f3268f0 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/promise.ts @@ -0,0 +1,7 @@ +import { ZodPromiseDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export function parsePromiseDef(def: ZodPromiseDef, refs: Refs): JsonSchema7Type | undefined { + return parseDef(def.type._def, refs); +} diff --git a/src/_vendor/zod-to-json-schema/parsers/readonly.ts b/src/_vendor/zod-to-json-schema/parsers/readonly.ts new file mode 100644 index 000000000..8cdfb3b1b --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/readonly.ts @@ -0,0 +1,7 @@ +import { ZodReadonlyDef } from 'zod/v3'; +import { parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export const parseReadonlyDef = (def: ZodReadonlyDef, refs: Refs) => { + return parseDef(def.innerType._def, refs); +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/record.ts b/src/_vendor/zod-to-json-schema/parsers/record.ts new file mode 100644 index 000000000..eefdeb7e0 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/record.ts @@ -0,0 +1,73 @@ +import { ZodFirstPartyTypeKind, ZodMapDef, ZodRecordDef, ZodTypeAny } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; +import { JsonSchema7EnumType } from './enum.js'; +import { JsonSchema7ObjectType } from './object.js'; +import { JsonSchema7StringType, parseStringDef } from './string.js'; + +type JsonSchema7RecordPropertyNamesType = + | Omit + | Omit; + +export type JsonSchema7RecordType = { + type: 'object'; + additionalProperties: JsonSchema7Type; + propertyNames?: JsonSchema7RecordPropertyNamesType; +}; + +export function parseRecordDef( + def: ZodRecordDef | ZodMapDef, + refs: Refs, +): JsonSchema7RecordType { + if (refs.target === 'openApi3' && def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { + return { + type: 'object', + required: def.keyType._def.values, + properties: def.keyType._def.values.reduce( + (acc: Record, key: string) => ({ + ...acc, + [key]: + parseDef(def.valueType._def, { + ...refs, + currentPath: [...refs.currentPath, 'properties', key], + }) ?? {}, + }), + {}, + ), + additionalProperties: false, + } satisfies JsonSchema7ObjectType as any; + } + + const schema: JsonSchema7RecordType = { + type: 'object', + additionalProperties: + parseDef(def.valueType._def, { + ...refs, + currentPath: [...refs.currentPath, 'additionalProperties'], + }) ?? {}, + }; + + if (refs.target === 'openApi3') { + return schema; + } + + if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodString && def.keyType._def.checks?.length) { + const keyType: JsonSchema7RecordPropertyNamesType = Object.entries( + parseStringDef(def.keyType._def, refs), + ).reduce((acc, [key, value]) => (key === 'type' ? acc : { ...acc, [key]: value }), {}); + + return { + ...schema, + propertyNames: keyType, + }; + } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { + return { + ...schema, + propertyNames: { + enum: def.keyType._def.values, + }, + }; + } + + return schema; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/set.ts b/src/_vendor/zod-to-json-schema/parsers/set.ts new file mode 100644 index 000000000..4b31ba61f --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/set.ts @@ -0,0 +1,36 @@ +import { ZodSetDef } from 'zod/v3'; +import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export type JsonSchema7SetType = { + type: 'array'; + uniqueItems: true; + items?: JsonSchema7Type | undefined; + minItems?: number; + maxItems?: number; + errorMessage?: ErrorMessages; +}; + +export function parseSetDef(def: ZodSetDef, refs: Refs): JsonSchema7SetType { + const items = parseDef(def.valueType._def, { + ...refs, + currentPath: [...refs.currentPath, 'items'], + }); + + const schema: JsonSchema7SetType = { + type: 'array', + uniqueItems: true, + items, + }; + + if (def.minSize) { + setResponseValueAndErrors(schema, 'minItems', def.minSize.value, def.minSize.message, refs); + } + + if (def.maxSize) { + setResponseValueAndErrors(schema, 'maxItems', def.maxSize.value, def.maxSize.message, refs); + } + + return schema; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/string.ts b/src/_vendor/zod-to-json-schema/parsers/string.ts new file mode 100644 index 000000000..123f7ce62 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/string.ts @@ -0,0 +1,400 @@ +// @ts-nocheck +import { ZodStringDef } from 'zod/v3'; +import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; +import { Refs } from '../Refs.js'; + +let emojiRegex: RegExp | undefined; + +/** + * Generated from the regular expressions found here as of 2024-05-22: + * https://github.com/colinhacks/zod/blob/master/src/types.ts. + * + * Expressions with /i flag have been changed accordingly. + */ +export const zodPatterns = { + /** + * `c` was changed to `[cC]` to replicate /i flag + */ + cuid: /^[cC][^\s-]{8,}$/, + cuid2: /^[0-9a-z]+$/, + ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/, + /** + * `a-z` was added to replicate /i flag + */ + email: /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/, + /** + * Constructed a valid Unicode RegExp + * + * Lazily instantiate since this type of regex isn't supported + * in all envs (e.g. React Native). + * + * See: + * https://github.com/colinhacks/zod/issues/2433 + * Fix in Zod: + * https://github.com/colinhacks/zod/commit/9340fd51e48576a75adc919bff65dbc4a5d4c99b + */ + emoji: () => { + if (emojiRegex === undefined) { + emojiRegex = RegExp('^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$', 'u'); + } + return emojiRegex; + }, + /** + * Unused + */ + uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, + /** + * Unused + */ + ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, + /** + * Unused + */ + ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, + base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, + nanoid: /^[a-zA-Z0-9_-]{21}$/, +} as const; + +export type JsonSchema7StringType = { + type: 'string'; + minLength?: number; + maxLength?: number; + format?: + | 'email' + | 'idn-email' + | 'uri' + | 'uuid' + | 'date-time' + | 'ipv4' + | 'ipv6' + | 'date' + | 'time' + | 'duration'; + pattern?: string; + allOf?: { + pattern: string; + errorMessage?: ErrorMessages<{ pattern: string }>; + }[]; + anyOf?: { + format: string; + errorMessage?: ErrorMessages<{ format: string }>; + }[]; + errorMessage?: ErrorMessages; + contentEncoding?: string; +}; + +export function parseStringDef(def: ZodStringDef, refs: Refs): JsonSchema7StringType { + const res: JsonSchema7StringType = { + type: 'string', + }; + + function processPattern(value: string): string { + return refs.patternStrategy === 'escape' ? escapeNonAlphaNumeric(value) : value; + } + + if (def.checks) { + for (const check of def.checks) { + switch (check.kind) { + case 'min': + setResponseValueAndErrors( + res, + 'minLength', + typeof res.minLength === 'number' ? Math.max(res.minLength, check.value) : check.value, + check.message, + refs, + ); + break; + case 'max': + setResponseValueAndErrors( + res, + 'maxLength', + typeof res.maxLength === 'number' ? Math.min(res.maxLength, check.value) : check.value, + check.message, + refs, + ); + + break; + case 'email': + switch (refs.emailStrategy) { + case 'format:email': + addFormat(res, 'email', check.message, refs); + break; + case 'format:idn-email': + addFormat(res, 'idn-email', check.message, refs); + break; + case 'pattern:zod': + addPattern(res, zodPatterns.email, check.message, refs); + break; + } + + break; + case 'url': + addFormat(res, 'uri', check.message, refs); + break; + case 'uuid': + addFormat(res, 'uuid', check.message, refs); + break; + case 'regex': + addPattern(res, check.regex, check.message, refs); + break; + case 'cuid': + addPattern(res, zodPatterns.cuid, check.message, refs); + break; + case 'cuid2': + addPattern(res, zodPatterns.cuid2, check.message, refs); + break; + case 'startsWith': + addPattern(res, RegExp(`^${processPattern(check.value)}`), check.message, refs); + break; + case 'endsWith': + addPattern(res, RegExp(`${processPattern(check.value)}$`), check.message, refs); + break; + + case 'datetime': + addFormat(res, 'date-time', check.message, refs); + break; + case 'date': + addFormat(res, 'date', check.message, refs); + break; + case 'time': + addFormat(res, 'time', check.message, refs); + break; + case 'duration': + addFormat(res, 'duration', check.message, refs); + break; + case 'length': + setResponseValueAndErrors( + res, + 'minLength', + typeof res.minLength === 'number' ? Math.max(res.minLength, check.value) : check.value, + check.message, + refs, + ); + setResponseValueAndErrors( + res, + 'maxLength', + typeof res.maxLength === 'number' ? Math.min(res.maxLength, check.value) : check.value, + check.message, + refs, + ); + break; + case 'includes': { + addPattern(res, RegExp(processPattern(check.value)), check.message, refs); + break; + } + case 'ip': { + if (check.version !== 'v6') { + addFormat(res, 'ipv4', check.message, refs); + } + if (check.version !== 'v4') { + addFormat(res, 'ipv6', check.message, refs); + } + break; + } + case 'emoji': + addPattern(res, zodPatterns.emoji, check.message, refs); + break; + case 'ulid': { + addPattern(res, zodPatterns.ulid, check.message, refs); + break; + } + case 'base64': { + switch (refs.base64Strategy) { + case 'format:binary': { + addFormat(res, 'binary' as any, check.message, refs); + break; + } + + case 'contentEncoding:base64': { + setResponseValueAndErrors(res, 'contentEncoding', 'base64', check.message, refs); + break; + } + + case 'pattern:zod': { + addPattern(res, zodPatterns.base64, check.message, refs); + break; + } + } + break; + } + case 'nanoid': { + addPattern(res, zodPatterns.nanoid, check.message, refs); + } + case 'toLowerCase': + case 'toUpperCase': + case 'trim': + break; + default: + ((_: never) => {})(check); + } + } + } + + return res; +} + +const escapeNonAlphaNumeric = (value: string) => + Array.from(value) + .map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `\\${c}`)) + .join(''); + +const addFormat = ( + schema: JsonSchema7StringType, + value: Required['format'], + message: string | undefined, + refs: Refs, +) => { + if (schema.format || schema.anyOf?.some((x) => x.format)) { + if (!schema.anyOf) { + schema.anyOf = []; + } + + if (schema.format) { + schema.anyOf!.push({ + format: schema.format, + ...(schema.errorMessage && + refs.errorMessages && { + errorMessage: { format: schema.errorMessage.format }, + }), + }); + delete schema.format; + if (schema.errorMessage) { + delete schema.errorMessage.format; + if (Object.keys(schema.errorMessage).length === 0) { + delete schema.errorMessage; + } + } + } + + schema.anyOf!.push({ + format: value, + ...(message && refs.errorMessages && { errorMessage: { format: message } }), + }); + } else { + setResponseValueAndErrors(schema, 'format', value, message, refs); + } +}; + +const addPattern = ( + schema: JsonSchema7StringType, + regex: RegExp | (() => RegExp), + message: string | undefined, + refs: Refs, +) => { + if (schema.pattern || schema.allOf?.some((x) => x.pattern)) { + if (!schema.allOf) { + schema.allOf = []; + } + + if (schema.pattern) { + schema.allOf!.push({ + pattern: schema.pattern, + ...(schema.errorMessage && + refs.errorMessages && { + errorMessage: { pattern: schema.errorMessage.pattern }, + }), + }); + delete schema.pattern; + if (schema.errorMessage) { + delete schema.errorMessage.pattern; + if (Object.keys(schema.errorMessage).length === 0) { + delete schema.errorMessage; + } + } + } + + schema.allOf!.push({ + pattern: processRegExp(regex, refs), + ...(message && refs.errorMessages && { errorMessage: { pattern: message } }), + }); + } else { + setResponseValueAndErrors(schema, 'pattern', processRegExp(regex, refs), message, refs); + } +}; + +// Mutate z.string.regex() in a best attempt to accommodate for regex flags when applyRegexFlags is true +const processRegExp = (regexOrFunction: RegExp | (() => RegExp), refs: Refs): string => { + const regex = typeof regexOrFunction === 'function' ? regexOrFunction() : regexOrFunction; + if (!refs.applyRegexFlags || !regex.flags) return regex.source; + + // Currently handled flags + const flags = { + i: regex.flags.includes('i'), // Case-insensitive + m: regex.flags.includes('m'), // `^` and `$` matches adjacent to newline characters + s: regex.flags.includes('s'), // `.` matches newlines + }; + + // The general principle here is to step through each character, one at a time, applying mutations as flags require. We keep track when the current character is escaped, and when it's inside a group /like [this]/ or (also) a range like /[a-z]/. The following is fairly brittle imperative code; edit at your peril! + + const source = flags.i ? regex.source.toLowerCase() : regex.source; + let pattern = ''; + let isEscaped = false; + let inCharGroup = false; + let inCharRange = false; + + for (let i = 0; i < source.length; i++) { + if (isEscaped) { + pattern += source[i]; + isEscaped = false; + continue; + } + + if (flags.i) { + if (inCharGroup) { + if (source[i].match(/[a-z]/)) { + if (inCharRange) { + pattern += source[i]; + pattern += `${source[i - 2]}-${source[i]}`.toUpperCase(); + inCharRange = false; + } else if (source[i + 1] === '-' && source[i + 2]?.match(/[a-z]/)) { + pattern += source[i]; + inCharRange = true; + } else { + pattern += `${source[i]}${source[i].toUpperCase()}`; + } + continue; + } + } else if (source[i].match(/[a-z]/)) { + pattern += `[${source[i]}${source[i].toUpperCase()}]`; + continue; + } + } + + if (flags.m) { + if (source[i] === '^') { + pattern += `(^|(?<=[\r\n]))`; + continue; + } else if (source[i] === '$') { + pattern += `($|(?=[\r\n]))`; + continue; + } + } + + if (flags.s && source[i] === '.') { + pattern += inCharGroup ? `${source[i]}\r\n` : `[${source[i]}\r\n]`; + continue; + } + + pattern += source[i]; + if (source[i] === '\\') { + isEscaped = true; + } else if (inCharGroup && source[i] === ']') { + inCharGroup = false; + } else if (!inCharGroup && source[i] === '[') { + inCharGroup = true; + } + } + + try { + const regexTest = new RegExp(pattern); + } catch { + console.warn( + `Could not convert regex pattern at ${refs.currentPath.join( + '/', + )} to a flag-independent form! Falling back to the flag-ignorant source`, + ); + return regex.source; + } + + return pattern; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/tuple.ts b/src/_vendor/zod-to-json-schema/parsers/tuple.ts new file mode 100644 index 000000000..79be7ded1 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/tuple.ts @@ -0,0 +1,54 @@ +import { ZodTupleDef, ZodTupleItems, ZodTypeAny } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export type JsonSchema7TupleType = { + type: 'array'; + minItems: number; + items: JsonSchema7Type[]; +} & ( + | { + maxItems: number; + } + | { + additionalItems?: JsonSchema7Type | undefined; + } +); + +export function parseTupleDef( + def: ZodTupleDef, + refs: Refs, +): JsonSchema7TupleType { + if (def.rest) { + return { + type: 'array', + minItems: def.items.length, + items: def.items + .map((x, i) => + parseDef(x._def, { + ...refs, + currentPath: [...refs.currentPath, 'items', `${i}`], + }), + ) + .reduce((acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), []), + additionalItems: parseDef(def.rest._def, { + ...refs, + currentPath: [...refs.currentPath, 'additionalItems'], + }), + }; + } else { + return { + type: 'array', + minItems: def.items.length, + maxItems: def.items.length, + items: def.items + .map((x, i) => + parseDef(x._def, { + ...refs, + currentPath: [...refs.currentPath, 'items', `${i}`], + }), + ) + .reduce((acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), []), + }; + } +} diff --git a/src/_vendor/zod-to-json-schema/parsers/undefined.ts b/src/_vendor/zod-to-json-schema/parsers/undefined.ts new file mode 100644 index 000000000..6864d8138 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/undefined.ts @@ -0,0 +1,9 @@ +export type JsonSchema7UndefinedType = { + not: {}; +}; + +export function parseUndefinedDef(): JsonSchema7UndefinedType { + return { + not: {}, + }; +} diff --git a/src/_vendor/zod-to-json-schema/parsers/union.ts b/src/_vendor/zod-to-json-schema/parsers/union.ts new file mode 100644 index 000000000..6018d8ce1 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/union.ts @@ -0,0 +1,119 @@ +import { ZodDiscriminatedUnionDef, ZodLiteralDef, ZodTypeAny, ZodUnionDef } from 'zod/v3'; +import { JsonSchema7Type, parseDef } from '../parseDef.js'; +import { Refs } from '../Refs.js'; + +export const primitiveMappings = { + ZodString: 'string', + ZodNumber: 'number', + ZodBigInt: 'integer', + ZodBoolean: 'boolean', + ZodNull: 'null', +} as const; +type ZodPrimitive = keyof typeof primitiveMappings; +type JsonSchema7Primitive = (typeof primitiveMappings)[keyof typeof primitiveMappings]; + +export type JsonSchema7UnionType = JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType; + +type JsonSchema7PrimitiveUnionType = + | { + type: JsonSchema7Primitive | JsonSchema7Primitive[]; + } + | { + type: JsonSchema7Primitive | JsonSchema7Primitive[]; + enum: (string | number | bigint | boolean | null)[]; + }; + +type JsonSchema7AnyOfType = { + anyOf: JsonSchema7Type[]; +}; + +export function parseUnionDef( + def: ZodUnionDef | ZodDiscriminatedUnionDef, + refs: Refs, +): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined { + if (refs.target === 'openApi3') return asAnyOf(def, refs); + + const options: readonly ZodTypeAny[] = + def.options instanceof Map ? Array.from(def.options.values()) : def.options; + + // This blocks tries to look ahead a bit to produce nicer looking schemas with type array instead of anyOf. + if ( + options.every((x) => x._def.typeName in primitiveMappings && (!x._def.checks || !x._def.checks.length)) + ) { + // all types in union are primitive and lack checks, so might as well squash into {type: [...]} + + const types = options.reduce((types: JsonSchema7Primitive[], x) => { + const type = primitiveMappings[x._def.typeName as ZodPrimitive]; //Can be safely casted due to row 43 + return type && !types.includes(type) ? [...types, type] : types; + }, []); + + return { + type: types.length > 1 ? types : types[0]!, + }; + } else if (options.every((x) => x._def.typeName === 'ZodLiteral' && !x.description)) { + // all options literals + + const types = options.reduce((acc: JsonSchema7Primitive[], x: { _def: ZodLiteralDef }) => { + const type = typeof x._def.value; + switch (type) { + case 'string': + case 'number': + case 'boolean': + return [...acc, type]; + case 'bigint': + return [...acc, 'integer' as const]; + case 'object': + if (x._def.value === null) return [...acc, 'null' as const]; + case 'symbol': + case 'undefined': + case 'function': + default: + return acc; + } + }, []); + + if (types.length === options.length) { + // all the literals are primitive, as far as null can be considered primitive + + const uniqueTypes = types.filter((x, i, a) => a.indexOf(x) === i); + return { + type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0]!, + enum: options.reduce( + (acc, x) => { + return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; + }, + [] as (string | number | bigint | boolean | null)[], + ), + }; + } + } else if (options.every((x) => x._def.typeName === 'ZodEnum')) { + return { + type: 'string', + enum: options.reduce( + (acc: string[], x) => [...acc, ...x._def.values.filter((x: string) => !acc.includes(x))], + [], + ), + }; + } + + return asAnyOf(def, refs); +} + +const asAnyOf = ( + def: ZodUnionDef | ZodDiscriminatedUnionDef, + refs: Refs, +): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined => { + const anyOf = ((def.options instanceof Map ? Array.from(def.options.values()) : def.options) as any[]) + .map((x, i) => + parseDef(x._def, { + ...refs, + currentPath: [...refs.currentPath, 'anyOf', `${i}`], + }), + ) + .filter( + (x): x is JsonSchema7Type => + !!x && (!refs.strictUnions || (typeof x === 'object' && Object.keys(x).length > 0)), + ); + + return anyOf.length ? { anyOf } : undefined; +}; diff --git a/src/_vendor/zod-to-json-schema/parsers/unknown.ts b/src/_vendor/zod-to-json-schema/parsers/unknown.ts new file mode 100644 index 000000000..a3c8d1d96 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/parsers/unknown.ts @@ -0,0 +1,5 @@ +export type JsonSchema7UnknownType = {}; + +export function parseUnknownDef(): JsonSchema7UnknownType { + return {}; +} diff --git a/src/_vendor/zod-to-json-schema/util.ts b/src/_vendor/zod-to-json-schema/util.ts new file mode 100644 index 000000000..1c2f50105 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/util.ts @@ -0,0 +1,11 @@ +import type { ZodSchema, ZodTypeDef } from 'zod/v3'; + +export const zodDef = (zodSchema: ZodSchema | ZodTypeDef): ZodTypeDef => { + return '_def' in zodSchema ? zodSchema._def : zodSchema; +}; + +export function isEmptyObj(obj: Object | null | undefined): boolean { + if (!obj) return true; + for (const _k in obj) return false; + return true; +} diff --git a/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts b/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts new file mode 100644 index 000000000..fe150d1b8 --- /dev/null +++ b/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts @@ -0,0 +1,120 @@ +import { ZodSchema } from 'zod/v3'; +import { Options, Targets } from './Options.js'; +import { JsonSchema7Type, parseDef } from './parseDef.js'; +import { getRefs } from './Refs.js'; +import { zodDef, isEmptyObj } from './util.js'; + +const zodToJsonSchema = ( + schema: ZodSchema, + options?: Partial> | string, +): (Target extends 'jsonSchema7' ? JsonSchema7Type : object) & { + $schema?: string; + definitions?: { + [key: string]: Target extends 'jsonSchema7' ? JsonSchema7Type + : Target extends 'jsonSchema2019-09' ? JsonSchema7Type + : object; + }; +} => { + const refs = getRefs(options); + + const name = + typeof options === 'string' ? options + : options?.nameStrategy === 'title' ? undefined + : options?.name; + + const main = + parseDef( + schema._def, + name === undefined ? refs : ( + { + ...refs, + currentPath: [...refs.basePath, refs.definitionPath, name], + } + ), + false, + ) ?? {}; + + const title = + typeof options === 'object' && options.name !== undefined && options.nameStrategy === 'title' ? + options.name + : undefined; + + if (title !== undefined) { + main.title = title; + } + + const definitions = (() => { + if (isEmptyObj(refs.definitions)) { + return undefined; + } + + const definitions: Record = {}; + const processedDefinitions = new Set(); + + // the call to `parseDef()` here might itself add more entries to `.definitions` + // so we need to continually evaluate definitions until we've resolved all of them + // + // we have a generous iteration limit here to avoid blowing up the stack if there + // are any bugs that would otherwise result in us iterating indefinitely + for (let i = 0; i < 500; i++) { + const newDefinitions = Object.entries(refs.definitions).filter( + ([key]) => !processedDefinitions.has(key), + ); + if (newDefinitions.length === 0) break; + + for (const [key, schema] of newDefinitions) { + definitions[key] = + parseDef( + zodDef(schema), + { ...refs, currentPath: [...refs.basePath, refs.definitionPath, key] }, + true, + ) ?? {}; + processedDefinitions.add(key); + } + } + + return definitions; + })(); + + const combined: ReturnType> = + name === undefined ? + definitions ? + { + ...main, + [refs.definitionPath]: definitions, + } + : main + : refs.nameStrategy === 'duplicate-ref' ? + { + ...main, + ...(definitions || refs.seenRefs.size ? + { + [refs.definitionPath]: { + ...definitions, + // only actually duplicate the schema definition if it was ever referenced + // otherwise the duplication is completely pointless + ...(refs.seenRefs.size ? { [name]: main } : undefined), + }, + } + : undefined), + } + : { + $ref: [...(refs.$refStrategy === 'relative' ? [] : refs.basePath), refs.definitionPath, name].join( + '/', + ), + [refs.definitionPath]: { + ...definitions, + [name]: main, + }, + }; + + if (refs.target === 'jsonSchema7') { + combined.$schema = 'http://json-schema.org/draft-07/schema#'; + } else if (refs.target === 'jsonSchema2019-09') { + combined.$schema = 'https://json-schema.org/draft/2019-09/schema#'; + } + + return combined; +}; + +export { zodToJsonSchema }; diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 70508ebaa..b9a1ce395 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -2,7 +2,7 @@ /* eslint-disable no-constant-binary-expression */ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client } from './index.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { RequestSchema, NotificationSchema, diff --git a/src/client/index.ts b/src/client/index.ts index 5770f9d7f..7c0f0d6ba 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -42,9 +42,35 @@ 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 AnySchema } from '../server/zod-compat.js'; import type { RequestHandlerExtra } from '../shared/protocol.js'; +// Helper interfaces for accessing Zod internal properties (same as in zod-compat.ts and protocol.ts) +interface ZodV3Internal { + _def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + shape?: Record | (() => Record); + value?: unknown; +} + +interface ZodV4Internal { + _zod?: { + def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + }; + value?: unknown; +} + /** * Elicitation default application helper. Applies defaults to the data based on the schema. * @@ -188,33 +214,56 @@ 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 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; diff --git a/src/client/v3/index.v3.test.ts b/src/client/v3/index.v3.test.ts new file mode 100644 index 000000000..23a79cef8 --- /dev/null +++ b/src/client/v3/index.v3.test.ts @@ -0,0 +1,1238 @@ +/* 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 * as z from 'zod/v3'; +import { + RequestSchema, + NotificationSchema, + ResultSchema, + LATEST_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, + InitializeRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + CallToolRequestSchema, + CreateMessageRequestSchema, + ElicitRequestSchema, + ListRootsRequestSchema, + ErrorCode +} from '../../types.js'; +import { Transport } from '../../shared/transport.js'; +import { Server } from '../../server/index.js'; +import { InMemoryTransport } from '../../inMemory.js'; + +/*** + * Test: Initialize with Matching Protocol Version + */ +test('should initialize with matching protocol version', async () => { + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + }, + instructions: 'test instructions' + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await client.connect(clientTransport); + + // Should have sent initialize with latest version + expect(clientTransport.send).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'initialize', + params: expect.objectContaining({ + protocolVersion: LATEST_PROTOCOL_VERSION + }) + }), + expect.objectContaining({ + relatedRequestId: undefined + }) + ); + + // Should have the instructions returned + expect(client.getInstructions()).toEqual('test instructions'); +}); + +/*** + * Test: Initialize with Supported Older Protocol Version + */ +test('should initialize with supported older protocol version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: OLD_VERSION, + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await client.connect(clientTransport); + + // Connection should succeed with the older version + expect(client.getServerVersion()).toEqual({ + name: 'test', + version: '1.0' + }); + + // Expect no instructions + expect(client.getInstructions()).toBeUndefined(); +}); + +/*** + * Test: Reject Unsupported Protocol Version + */ +test('should reject unsupported protocol version', async () => { + const clientTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.method === 'initialize') { + clientTransport.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: 'invalid-version', + capabilities: {}, + serverInfo: { + name: 'test', + version: '1.0' + } + } + }); + } + return Promise.resolve(); + }) + }; + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + await expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: invalid-version"); + + expect(clientTransport.close).toHaveBeenCalled(); +}); + +/*** + * Test: Connect New Client to Old Supported Server Version + */ +test('should connect new client to old, supported server version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: OLD_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'old server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'new client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(client.getServerVersion()).toEqual({ + name: 'old server', + version: '1.0' + }); +}); + +/*** + * Test: Version Negotiation with Old Client and Newer Server + */ +test('should negotiate version when client is old, and newer server supports its version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'old client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(client.getServerVersion()).toEqual({ + name: 'new server', + version: '1.0' + }); +}); + +/*** + * Test: Throw when Old Client and Server Version Mismatch + */ +test("should throw when client is old, and server doesn't support its version", async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + const FUTURE_VERSION = 'FUTURE_VERSION'; + const server = new Server( + { + name: 'new server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: FUTURE_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'new server', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'old client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([ + expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: FUTURE_VERSION"), + server.connect(serverTransport) + ]); +}); + +/*** + * Test: Respect Server Capabilities + */ +test('should respect server capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ); + + server.setRequestHandler(InitializeRequestSchema, _request => ({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { + resources: {}, + tools: {} + }, + serverInfo: { + name: 'test', + version: '1.0' + } + })); + + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + + server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: [] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + }, + enforceStrictCapabilities: true + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Server supports resources and tools, but not prompts + expect(client.getServerCapabilities()).toEqual({ + resources: {}, + tools: {} + }); + + // These should work + await expect(client.listResources()).resolves.not.toThrow(); + await expect(client.listTools()).resolves.not.toThrow(); + + // These should throw because prompts, logging, and completions are not supported + await expect(client.listPrompts()).rejects.toThrow('Server does not support prompts'); + await expect(client.setLoggingLevel('error')).rejects.toThrow('Server does not support logging'); + await expect( + client.complete({ + ref: { type: 'ref/prompt', name: 'test' }, + argument: { name: 'test', value: 'test' } + }) + ).rejects.toThrow('Server does not support completions'); +}); + +/*** + * Test: Respect Client Notification Capabilities + */ +test('should respect client notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: { + listChanged: true + } + } + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // This should work because the client has the roots.listChanged capability + await expect(client.sendRootsListChanged()).resolves.not.toThrow(); + + // Create a new client without the roots.listChanged capability + const clientWithoutCapability = new Client( + { + name: 'test client without capability', + version: '1.0' + }, + { + capabilities: {}, + enforceStrictCapabilities: true + } + ); + + await clientWithoutCapability.connect(clientTransport); + + // This should throw because the client doesn't have the roots.listChanged capability + await expect(clientWithoutCapability.sendRootsListChanged()).rejects.toThrow(/^Client does not support/); +}); + +/*** + * Test: Respect Server Notification Capabilities + */ +test('should respect server notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + logging: {}, + resources: { + listChanged: true + } + } + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // These should work because the server has the corresponding capabilities + await expect(server.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow(); + await expect(server.sendResourceListChanged()).resolves.not.toThrow(); + + // This should throw because the server doesn't have the tools capability + await expect(server.sendToolListChanged()).rejects.toThrow('Server does not support notifying of tool list changes'); +}); + +/*** + * Test: Only Allow setRequestHandler for Declared Capabilities + */ +test('should only allow setRequestHandler for declared capabilities', () => { + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // This should work because sampling is a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + })); + }).not.toThrow(); + + // This should throw because roots listing is not a declared capability + expect(() => { + client.setRequestHandler(ListRootsRequestSchema, () => ({})); + }).toThrow('Client does not support roots capability'); +}); + +test('should allow setRequestHandler for declared elicitation capability', () => { + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // This should work because elicitation is a declared capability + expect(() => { + client.setRequestHandler(ElicitRequestSchema, () => ({ + action: 'accept', + content: { + username: 'test-user', + confirmed: true + } + })); + }).not.toThrow(); + + // This should throw because sampling is not a declared capability + expect(() => { + client.setRequestHandler(CreateMessageRequestSchema, () => ({ + model: 'test-model', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + })); + }).toThrow('Client does not support sampling capability'); +}); + +/*** + * Test: 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() + }) + }); + + 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 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: Handle Client Cancelling a Request + */ +test('should handle client cancelling a request', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {} + } + } + ); + + // Set up server to delay responding to listResources + server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + resources: [] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Set up abort controller + const controller = new AbortController(); + + // Issue request but cancel it immediately + const listResourcesPromise = client.listResources(undefined, { + signal: controller.signal + }); + controller.abort('Cancelled by test'); + + // Request should be rejected + await expect(listResourcesPromise).rejects.toBe('Cancelled by test'); +}); + +/*** + * Test: Handle Request Timeout + */ +test('should handle request timeout', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + resources: {} + } + } + ); + + // Set up server with a delayed response + server.setRequestHandler(ListResourcesRequestSchema, async (_request, extra) => { + const timer = new Promise(resolve => { + const timeout = setTimeout(resolve, 100); + extra.signal.addEventListener('abort', () => clearTimeout(timeout)); + }); + + await timer; + return { + resources: [] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: {} + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Request with 0 msec timeout should fail immediately + await expect(client.listResources(undefined, { timeout: 0 })).rejects.toMatchObject({ + code: ErrorCode.RequestTimeout + }); +}); + +describe('outputSchema validation', () => { + /*** + * Test: Validate structuredContent Against outputSchema + */ + test('should validate structuredContent against outputSchema', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'test-tool') { + return { + structuredContent: { result: 'success', count: 42 } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'test-tool' }); + expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); + }); + + /*** + * Test: Throw Error when structuredContent Does Not Match Schema + */ + test('should throw error when structuredContent does not match schema', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'test-tool') { + // Return invalid structured content (count is string instead of number) + return { + structuredContent: { result: 'success', count: 'not a number' } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(/Structured content does not match the tool's output schema/); + }); + + /*** + * Test: Throw Error when Tool with outputSchema Returns No structuredContent + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'test-tool') { + // Return content instead of structuredContent + return { + content: [{ type: 'text', text: 'This should be structured content' }] + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw error + await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( + /Tool test-tool has an output schema but did not return structured content/ + ); + }); + + /*** + * Test: Handle Tools Without outputSchema Normally + */ + test('should handle tools without outputSchema normally', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + } + // No outputSchema + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'test-tool') { + // Return regular content + return { + content: [{ type: 'text', text: 'Normal response' }] + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should work normally without validation + const result = await client.callTool({ name: 'test-tool' }); + expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); + }); + + /*** + * Test: Handle Complex JSON Schema Validation + */ + test('should handle complex JSON schema validation', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'complex-tool', + description: 'A tool with complex schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'integer', minimum: 0, maximum: 120 }, + active: { type: 'boolean' }, + tags: { + type: 'array', + items: { type: 'string' }, + minItems: 1 + }, + metadata: { + type: 'object', + properties: { + created: { type: 'string' } + }, + required: ['created'] + } + }, + required: ['name', 'age', 'active', 'tags', 'metadata'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'complex-tool') { + return { + structuredContent: { + name: 'John Doe', + age: 30, + active: true, + tags: ['user', 'admin'], + metadata: { + created: '2023-01-01T00:00:00Z' + } + } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should validate successfully + const result = await client.callTool({ name: 'complex-tool' }); + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { name: string; age: number }; + expect(structuredContent.name).toBe('John Doe'); + expect(structuredContent.age).toBe(30); + }); + + /*** + * Test: Fail Validation with Additional Properties When Not Allowed + */ + test('should fail validation with additional properties when not allowed', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + // Set up server handlers + server.setRequestHandler(InitializeRequestSchema, async request => ({ + protocolVersion: request.params.protocolVersion, + capabilities: {}, + serverInfo: { + name: 'test-server', + version: '1.0.0' + } + })); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'strict-tool', + description: 'A tool with strict schema', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + name: { type: 'string' } + }, + required: ['name'], + additionalProperties: false + } + } + ] + })); + + server.setRequestHandler(CallToolRequestSchema, async request => { + if (request.params.name === 'strict-tool') { + // Return structured content with extra property + return { + structuredContent: { + name: 'John', + extraField: 'not allowed' + } + }; + } + throw new Error('Unknown tool'); + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // List tools to cache the schemas + await client.listTools(); + + // Call the tool - should throw validation error due to additional property + await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( + /Structured content does not match the tool's output schema/ + ); + }); +}); 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..9d18039b7 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'; 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/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index ad5a01bdc..4fa13c78a 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', diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index 629b01519..bd61e6104 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -12,7 +12,7 @@ import { ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; describe('Streamable HTTP Transport Session Management', () => { // Function to set up the server with optional session management diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index c8393dfe1..ca5895e58 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -6,7 +6,7 @@ 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 * as z from 'zod/v4'; import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js'; describe('Transport resumability', () => { diff --git a/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts b/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts new file mode 100644 index 000000000..b47306142 --- /dev/null +++ b/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts @@ -0,0 +1,357 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { + CallToolResultSchema, + ListToolsResultSchema, + ListResourcesResultSchema, + ListPromptsResultSchema, + LATEST_PROTOCOL_VERSION +} from '../../types.js'; +import * as z from 'zod/v3'; + +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: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a test prompt' + } + } + ] + })); + + 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(); + + // 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 + ); + + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]); + + // Clean up + await transport.close(); + }); + + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); + + await client.connect(transport); + + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + + // Clean up + await transport.close(); + }); + }); + + describe('Stateful Mode', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + + beforeEach(async () => { + const setup = await setupServer(true); + server = setup.server; + mcpServer = setup.mcpServer; + serverTransport = setup.serverTransport; + baseUrl = setup.baseUrl; + }); + + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); + + it('should operate with session management', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify that a session ID was set + expect(transport.sessionId).toBeDefined(); + expect(typeof transport.sessionId).toBe('string'); + + // List available tools + const toolsResult = await client.request( + { + method: 'tools/list', + params: {} + }, + 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 + ); + + // Verify tool result + expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]); + + // Clean up + await transport.close(); + }); + }); +}); diff --git a/src/integration-tests/v3/taskResumability.v3.test.ts b/src/integration-tests/v3/taskResumability.v3.test.ts new file mode 100644 index 000000000..7c7ea927e --- /dev/null +++ b/src/integration-tests/v3/taskResumability.v3.test.ts @@ -0,0 +1,270 @@ +import { createServer, type Server } from 'node:http'; +import { AddressInfo } from 'node:net'; +import { randomUUID } from 'node:crypto'; +import { Client } from '../../client/index.js'; +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 * as z from 'zod/v3'; +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++) { + 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` }] + }; + } + ); + + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); + + // Connect the transport to the MCP server + await mcpServer.connect(serverTransport); + + // Create and start an HTTP server + server = createServer(async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Start the server on a random port + baseUrl = await 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' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + await client.connect(transport); + + // Verify session ID was generated + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + it('should have session ID functionality', async () => { + // The ability to store a session ID when connecting + const client = new Client({ + name: 'test-client-reconnection', + version: '1.0.0' + }); + + const transport = new StreamableHTTPClientTransport(baseUrl); + + // Make sure the client can connect and get a session ID + await client.connect(transport); + expect(transport.sessionId).toBeDefined(); + + // Clean up + await transport.close(); + }); + + // This test demonstrates the capability to resume long-running tools + // across client disconnection/reconnection + it('should resume long-running notifications with lastEventId', async () => { + // Create unique client ID for this test + const clientTitle = 'test-client-long-running'; + const notifications = []; + let lastEventId: string | undefined; + + // Create first client + const client1 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // Connect first client + const transport1 = new StreamableHTTPClientTransport(baseUrl); + await client1.connect(transport1); + const sessionId = transport1.sessionId; + expect(sessionId).toBeDefined(); + + // Start a long-running notification stream with tracking of lastEventId + const onLastEventIdUpdate = vi.fn((eventId: string) => { + lastEventId = eventId; + }); + expect(lastEventId).toBeUndefined(); + // Start the notification tool with event tracking using request + const toolPromise = client1.request( + { + method: 'tools/call', + params: { + name: 'run-notifications', + arguments: { + count: 3, + interval: 10 + } + } + }, + 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); + } + }); + + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); + + // Wait for the rejection to be handled + await catchPromise; + + // Create second client with same client ID + const client2 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); + + // Set up notification handler for second client + client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); + + // 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 + } + } + }, + CallToolResultSchema, + { + resumptionToken: lastEventId, // Pass the lastEventId from the previous session + onresumptiontoken: onLastEventIdUpdate + } + ); + + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(1); + + // Clean up + await transport2.close(); + }); +}); 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/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/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/completable.test.ts b/src/server/completable.test.ts index b5effc272..fa836fec5 100644 --- a/src/server/completable.test.ts +++ b/src/server/completable.test.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; -import { completable } from './completable.js'; +import * as z from 'zod/v4'; +import { completable, getCompleter } from './completable.js'; describe('completable', () => { it('preserves types and values of underlying schema', () => { @@ -14,27 +14,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..65f306584 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -1,79 +1,70 @@ -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 any)[COMPLETABLE_SYMBOL] as CompletableMeta | undefined; + 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/index.test.ts b/src/server/index.test.ts index a660c3085..010399b52 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import type { Transport } from '../shared/transport.js'; diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index e2291481a..f25f78759 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import { getDisplayName } from '../shared/metadataUtils.js'; @@ -95,17 +95,18 @@ describe('McpServer', () => { }, async ({ steps }, { sendNotification, _meta }) => { const progressToken = _meta?.progressToken; + const stepCount = steps as number; if (progressToken) { // Send progress notification for each step - for (let i = 1; i <= steps; i++) { + for (let i = 1; i <= stepCount; i++) { await sendNotification({ method: 'notifications/progress', params: { progressToken, progress: i, - total: steps, - message: `Completed step ${i} of ${steps}` + total: stepCount, + message: `Completed step ${i} of ${stepCount}` } }); } diff --git a/src/server/mcp.ts b/src/server/mcp.ts index bee3b76ec..7e0975d11 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1,6 +1,17 @@ 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, + isZ4Schema, + getObjectShape, + objectFromShape +} from './zod-compat.js'; +import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; import { Implementation, Tool, @@ -36,7 +47,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 +98,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 +117,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 +160,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}: ${(parseResult as any).error.message}` ); } @@ -169,11 +190,12 @@ 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}: ${(parseResult as any).error.message}` ); } } @@ -212,7 +234,7 @@ export class McpServer { return; } - this.server.assertCanSetRequestHandler(CompleteRequestSchema.shape.method.value); + this.server.assertCanSetRequestHandler(getMethodValue(CompleteRequestSchema)); this.server.registerCapabilities({ completions: {} @@ -250,13 +272,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 +317,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 +400,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 +436,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}: ${(parseResult as any).error.message}` ); } @@ -632,7 +659,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 +672,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 +686,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 +717,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 +753,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 +768,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 +779,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 +807,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 +826,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 +841,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 +849,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 +875,7 @@ export class McpServer { outputSchema, annotations, _meta, - cb as ToolCallback + cb as ToolCallback ); } @@ -1042,27 +1078,30 @@ 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 +export type ToolCallback = Args extends ZodRawShapeCompat ? ( - args: z.objectOutputType, + args: ShapeOutput, extra: RequestHandlerExtra ) => CallToolResult | Promise - : Args extends ZodType - ? (args: T, 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 +1120,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 +1131,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 +1146,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,13 +1225,11 @@ export type RegisteredResourceTemplate = { remove(): void; }; -type PromptArgsRawShape = { - [k: string]: ZodType | ZodOptional>; -}; +type PromptArgsRawShape = ZodRawShapeCompat; export type PromptCallback = Args extends PromptArgsRawShape ? ( - args: z.objectOutputType, + args: ShapeOutput, extra: RequestHandlerExtra ) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; @@ -1200,7 +1237,7 @@ export type PromptCallback; + argsSchema?: AnyObjectSchema; callback: PromptCallback; enabled: boolean; enable(): void; @@ -1216,14 +1253,48 @@ 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 = (field as any).description ?? (field as any)._def?.description; + // Check if optional - works for both v3 and v4 + const isOptional = (field as any).isOptional?.() ?? (field as any)._def?.typeName === 'ZodOptional'; + 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 v4Def = isZ4Schema(methodSchema) ? (methodSchema as any)._zod?.def : undefined; + const legacyDef = (methodSchema as any)._def; + + const candidates = [ + v4Def?.value, + legacyDef?.value, + Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, + Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, + (methodSchema as any).value + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string') { + return candidate; + } + } + + 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 7dae26083..34ac071fe 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -5,7 +5,7 @@ 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 * as z from 'zod/v4'; import { CallToolResult, JSONRPCMessage } from '../types.js'; const createMockResponse = () => { diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index 8d78aad67..b5b169951 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -4,7 +4,7 @@ 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 * as z from 'zod/v4'; import { AuthInfo } from './auth/types.js'; async function getFreePort() { diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 7f0feedc8..0f588514d 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -1,7 +1,7 @@ import { Server } from './index.js'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; -import { z } from 'zod'; +import * as z from 'zod/v4'; import { McpServer, ResourceTemplate } from './mcp.js'; describe('Title field backwards compatibility', () => { diff --git a/src/server/v3/completable.v3.test.ts b/src/server/v3/completable.v3.test.ts new file mode 100644 index 000000000..111874e1e --- /dev/null +++ b/src/server/v3/completable.v3.test.ts @@ -0,0 +1,54 @@ +import * as z from 'zod/v3'; +import { completable, getCompleter } from '../completable.js'; + +describe('completable', () => { + it('preserves types and values of underlying schema', () => { + const baseSchema = z.string(); + const schema = completable(baseSchema, () => []); + + expect(schema.parse('test')).toBe('test'); + expect(() => schema.parse(123)).toThrow(); + }); + + it('provides access to completion function', async () => { + const completions = ['foo', 'bar', 'baz']; + const schema = completable(z.string(), () => completions); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); + }); + + it('allows async completion functions', async () => { + const completions = ['foo', 'bar', 'baz']; + const schema = completable(z.string(), async () => completions); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('')).toEqual(completions); + }); + + it('passes current value to completion function', async () => { + const schema = completable(z.string(), value => [value + '!']); + + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!('test')).toEqual(['test!']); + }); + + it('works with number schemas', async () => { + const schema = completable(z.number(), () => [1, 2, 3]); + + expect(schema.parse(1)).toBe(1); + const completer = getCompleter(schema); + expect(completer).toBeDefined(); + expect(await completer!(0)).toEqual([1, 2, 3]); + }); + + it('preserves schema description', () => { + const desc = 'test description'; + const schema = completable(z.string().describe(desc), () => []); + + expect(schema.description).toBe(desc); + }); +}); diff --git a/src/server/v3/index.v3.test.ts b/src/server/v3/index.v3.test.ts new file mode 100644 index 000000000..a6c02b208 --- /dev/null +++ b/src/server/v3/index.v3.test.ts @@ -0,0 +1,951 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as z from 'zod/v3'; +import { Client } from '../../client/index.js'; +import { InMemoryTransport } from '../../inMemory.js'; +import type { Transport } from '../../shared/transport.js'; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + ErrorCode, + LATEST_PROTOCOL_VERSION, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListToolsRequestSchema, + type LoggingMessageNotification, + NotificationSchema, + RequestSchema, + ResultSchema, + SetLevelRequestSchema, + SUPPORTED_PROTOCOL_VERSIONS +} from '../../types.js'; +import { Server } from '../index.js'; + +test('should accept latest protocol version', async () => { + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + }, + instructions: 'Test instructions' + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + instructions: 'Test instructions' + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with latest version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should accept supported older protocol version', async () => { + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: OLD_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + } + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with older version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: OLD_VERSION, + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should handle unsupported protocol version', async () => { + let sendPromiseResolve: (value: unknown) => void; + const sendPromise = new Promise(resolve => { + sendPromiseResolve = resolve; + }); + + const serverTransport: Transport = { + start: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockImplementation(message => { + if (message.id === 1 && message.result) { + expect(message.result).toEqual({ + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: expect.any(Object), + serverInfo: { + name: 'test server', + version: '1.0' + } + }); + sendPromiseResolve(undefined); + } + return Promise.resolve(); + }) + }; + + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + } + } + ); + + await server.connect(serverTransport); + + // Simulate initialize request with unsupported version + serverTransport.onmessage?.({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 'invalid-version', + capabilities: {}, + clientInfo: { + name: 'test client', + version: '1.0' + } + } + }); + + await expect(sendPromise).resolves.toBeUndefined(); +}); + +test('should respect client capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Implement request handler for sampling/createMessage + client.setRequestHandler(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)]); + + 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/); +}); + +test('should validate elicitation response against requested schema', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client to return valid response + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + name: 'John Doe', + email: 'john@example.com', + age: 30 + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Test with valid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1 + }, + email: { + type: 'string', + minLength: 1 + }, + age: { + type: 'integer', + minimum: 0, + maximum: 150 + } + }, + required: ['name', 'email'] + } + }) + ).resolves.toEqual({ + action: 'accept', + content: { + name: 'John Doe', + email: 'john@example.com', + age: 30 + } + }); +}); + +test('should reject elicitation response with invalid data', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + // Set up client to return invalid response (missing required field, invalid age) + client.setRequestHandler(ElicitRequestSchema, _request => ({ + action: 'accept', + content: { + email: '', // Invalid - too short + age: -5 // Invalid age + } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Test with invalid response + await expect( + server.elicitInput({ + message: 'Please provide your information', + requestedSchema: { + type: 'object', + properties: { + name: { + type: 'string', + minLength: 1 + }, + email: { + type: 'string', + minLength: 1 + }, + age: { + type: 'integer', + minimum: 0, + maximum: 150 + } + }, + required: ['name', 'email'] + } + }) + ).rejects.toThrow(/does not match requested schema/); +}); + +test('should allow elicitation reject and cancel without validation', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + + let requestCount = 0; + client.setRequestHandler(ElicitRequestSchema, _request => { + requestCount++; + if (requestCount === 1) { + return { action: 'decline' }; + } else { + return { action: 'cancel' }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const schema = { + type: 'object' as const, + properties: { + name: { type: 'string' as const } + }, + required: ['name'] + }; + + // Test reject - should not validate + await expect( + server.elicitInput({ + message: 'Please provide your name', + requestedSchema: schema + }) + ).resolves.toEqual({ + action: 'decline' + }); + + // Test cancel - should not validate + await expect( + server.elicitInput({ + message: 'Please provide your name', + requestedSchema: schema + }) + ).resolves.toEqual({ + action: 'cancel' + }); +}); + +test('should respect server notification capabilities', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const [_clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + + // This should work because logging is supported by the server + await expect( + server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); + + // This should throw because resource notificaitons are not supported by the server + await expect(server.sendResourceUpdated({ uri: 'test://resource' })).rejects.toThrow(/^Server does not support/); +}); + +test('should only allow setRequestHandler for declared capabilities', () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {} + } + } + ); + + // These should work because the capabilities are declared + expect(() => { + server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] })); + }).not.toThrow(); + + expect(() => { + server.setRequestHandler(ListResourcesRequestSchema, () => ({ + resources: [] + })); + }).not.toThrow(); + + // These should throw because the capabilities are not declared + expect(() => { + server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] })); + }).toThrow(/^Server does not support tools/); + + expect(() => { + server.setRequestHandler(SetLevelRequestSchema, () => ({})); + }).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( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + // Set up client to delay responding to createMessage + client.setRequestHandler(CreateMessageRequestSchema, async (_request, _extra) => { + await new Promise(resolve => setTimeout(resolve, 1000)); + return { + model: 'test', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Set up abort controller + const controller = new AbortController(); + + // Issue request but cancel it immediately + const createMessagePromise = server.createMessage( + { + messages: [], + maxTokens: 10 + }, + { + signal: controller.signal + } + ); + controller.abort('Cancelled by test'); + + // Request should be rejected + await expect(createMessagePromise).rejects.toBe('Cancelled by test'); +}); + +test('should handle request timeout', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: {} + } + ); + + // Set up client that delays responses + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + sampling: {} + } + } + ); + + client.setRequestHandler(CreateMessageRequestSchema, async (_request, extra) => { + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 100); + extra.signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(extra.signal.reason); + }); + }); + + return { + model: 'test', + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Request with 0 msec timeout should fail immediately + await expect( + server.createMessage( + { + messages: [], + maxTokens: 10 + }, + { timeout: 0 } + ) + ).rejects.toMatchObject({ + code: ErrorCode.RequestTimeout + }); +}); + +/* + Test automatic log level handling for transports with and without sessionId + */ +test('should respect log level for transport without sessionId', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + expect(clientTransport.sessionId).toEqual(undefined); + + // Client sets logging level to warning + await client.setLoggingLevel('warning'); + + // This one will make it through + const warningParams: LoggingMessageNotification['params'] = { + level: 'warning', + logger: 'test server', + data: 'Warning message' + }; + + // This one will not + const debugParams: LoggingMessageNotification['params'] = { + level: 'debug', + logger: 'test server', + data: 'Debug message' + }; + + // Test the one that makes it through + clientTransport.onmessage = vi.fn().mockImplementation(message => { + expect(message).toEqual({ + jsonrpc: '2.0', + method: 'notifications/message', + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams); + expect(clientTransport.onmessage).toHaveBeenCalled(); +}); + +test('should respect log level for transport with sessionId', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + // Add a session id to the transports + const SESSION_ID = 'test-session-id'; + clientTransport.sessionId = SESSION_ID; + serverTransport.sessionId = SESSION_ID; + + expect(clientTransport.sessionId).toBeDefined(); + expect(serverTransport.sessionId).toBeDefined(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Client sets logging level to warning + await client.setLoggingLevel('warning'); + + // This one will make it through + const warningParams: LoggingMessageNotification['params'] = { + level: 'warning', + logger: 'test server', + data: 'Warning message' + }; + + // This one will not + const debugParams: LoggingMessageNotification['params'] = { + level: 'debug', + logger: 'test server', + data: 'Debug message' + }; + + // Test the one that makes it through + clientTransport.onmessage = vi.fn().mockImplementation(message => { + expect(message).toEqual({ + jsonrpc: '2.0', + method: 'notifications/message', + params: warningParams + }); + }); + + // This one will not make it through + await server.sendLoggingMessage(debugParams, SESSION_ID); + expect(clientTransport.onmessage).not.toHaveBeenCalled(); + + // This one will, triggering the above test in clientTransport.onmessage + await server.sendLoggingMessage(warningParams, SESSION_ID); + expect(clientTransport.onmessage).toHaveBeenCalled(); +}); diff --git a/src/server/v3/mcp.v3.test.ts b/src/server/v3/mcp.v3.test.ts new file mode 100644 index 000000000..8348906d1 --- /dev/null +++ b/src/server/v3/mcp.v3.test.ts @@ -0,0 +1,4519 @@ +import * as z from 'zod/v3'; +import { Client } from '../../client/index.js'; +import { InMemoryTransport } from '../../inMemory.js'; +import { getDisplayName } from '../../shared/metadataUtils.js'; +import { UriTemplate } from '../../shared/uriTemplate.js'; +import { + CallToolResultSchema, + CompleteResultSchema, + ElicitRequestSchema, + GetPromptResultSchema, + ListPromptsResultSchema, + ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListToolsResultSchema, + LoggingMessageNotificationSchema, + type Notification, + ReadResourceResultSchema, + type TextContent +} from '../../types.js'; +import { completable } from '../completable.js'; +import { McpServer, ResourceTemplate } from '../mcp.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(); + }); + + /*** + * Test: Notification Sending via Server + */ + test('should allow sending notifications via Server', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // This should work because we're using the underlying server + await expect( + mcpServer.server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); + + expect(notifications).toMatchObject([ + { + method: 'notifications/message', + params: { + level: 'info', + data: 'Test log message' + } + } + ]); + }); + + /*** + * Test: Progress Notification with Message Field + */ + test('should send progress notifications with message field', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Create a tool that sends progress updates + mcpServer.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}` + } + }); + } + } + + return { + content: [ + { + type: 'text' as const, + text: `Operation completed with ${steps} steps` + } + ] + }; + } + ); + + const progressUpdates: Array<{ + progress: number; + total?: number; + message?: string; + }> = []; + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool with progress tracking + await client.request( + { + method: 'tools/call', + params: { + name: 'long-operation', + arguments: { steps: 3 }, + _meta: { + progressToken: 'progress-test-1' + } + } + }, + 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 = vi.fn().mockResolvedValue({ + resources: [{ name: 'Test', uri: 'test://example' }] + }); + + const template = new ResourceTemplate('test://{id}', { list }); + expect(template.listCallback).toBe(list); + + const abortController = new AbortController(); + const result = await template.listCallback?.({ + signal: abortController.signal, + requestId: 'not-implemented', + sendRequest: () => { + throw new Error('Not implemented'); + }, + sendNotification: () => { + throw new Error('Not implemented'); + } + }); + expect(result?.resources).toHaveLength(1); + expect(list).toHaveBeenCalled(); + }); +}); + +describe('tool()', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /*** + * Test: Zero-Argument Tool Registration + */ + test('should register zero-argument tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toEqual({ + type: 'object', + properties: {} + }); + + // Adding the tool before the connection was established means no notification was sent + expect(notifications).toHaveLength(0); + + // Adding another tool triggers the update notification + mcpServer.tool('test2', async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { + method: 'notifications/tools/list_changed' + } + ]); + }); + + /*** + * Test: Updating Existing Tool + */ + test('should update existing tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Initial response' + } + ] + })); + + // Update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the tool and verify we get the updated response + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test' + } + }, + 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); + }); + + /*** + * 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 }) => ({ + content: [ + { + type: 'text', + text: `Initial: ${name}` + } + ] + }) + ); + + // 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}` + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify the schema was updated + const listResult = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(listResult.tools[0].inputSchema).toMatchObject({ + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); + + // Call the tool with the new schema + const callResult = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 42 + } + } + }, + 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); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); + + // Now delete the tool + tool.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { method: 'notifications/tools/list_changed' }, + { method: 'notifications/tools/list_changed' } + ]); + }); + + /*** + * Test: 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' + }); + + // 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); + }); + + /*** + * 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'); + }); + + /*** + * 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 + }); + }); + + /*** + * 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); + }); + + /*** + * 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 + }); + 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', text: 'Test response' }] + }) + ); + + 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' }] + }) + ); + + 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 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); + }); + + /*** + * 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: expect.stringContaining('Input validation error: Invalid arguments for tool test') + } + ]) + ); + + 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: 'Test response' + } + ] + })); + + expect(() => { + mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Test response 2' + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Tool Registration + */ + test('should allow registering multiple tools', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.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() + } + }, + 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'] + }); + + // 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); + }); + + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: { + 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' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining( + 'Output validation error: Tool test has an output schema but no structured content was provided' + ) + } + ]) + ); + }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should skip outputSchema validation when isError is true', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string() + } + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ], + isError: true + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }) + ).resolves.toStrictEqual({ + content: [ + { + type: 'text', + text: `Processed: hello` + } + ], + isError: true + }); + }); + + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test('should fail schema validation when tool returns invalid structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns invalid data + mcpServer.registerTool( + 'test', + { + description: 'Test tool with invalid structured output', + inputSchema: { + 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') + } + ]) + ); + }); + + /*** + * 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)]); + + await client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); + + expect(receivedSessionId).toBe('test-session-123'); + }); + + /*** + * 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 client = new Client({ + name: 'test client', + version: '1.0' + }); + + 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 [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'request-id-test' + } + }, + CallToolResultSchema + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Received request ID:') + } + ]) + ); + }); + + /*** + * Test: Send Notification within Tool Call + */ + test('should provide sendNotification within tool call', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedLogMessage: string | undefined; + const loggingMessage = 'hello here is log message 1'; + + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + receivedLogMessage = notification.params.data as string; + }); + + mcpServer.tool('test-tool', async ({ sendNotification }) => { + await sendNotification({ + method: 'notifications/message', + params: { level: 'debug', data: loggingMessage } + }); + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); + expect(receivedLogMessage).toBe(loggingMessage); + }); + + /*** + * Test: Client to Server Tool Call + */ + test('should allow client to call server tools', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'Test tool', + { + input: z.string() + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }, + 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 [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'error-test' + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Tool execution failed' + } + ]); + }); + + /*** + * 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' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + 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)]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'nonexistent-tool' + } + }, + CallToolResultSchema + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Tool nonexistent-tool not found') + } + ]) + ); + }); + + /*** + * Test: Tool Registration with _meta field + */ + test('should register tool with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; + + mcpServer.registerTool( + 'test-with-meta', + { + description: 'A tool with _meta field', + inputSchema: { name: z.string() }, + _meta: metaData + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-with-meta'); + expect(result.tools[0].description).toBe('A tool with _meta field'); + expect(result.tools[0]._meta).toEqual(metaData); + }); + + /*** + * Test: Tool Registration without _meta field should have undefined _meta + */ + test('should register tool without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test-without-meta', + { + description: 'A tool without _meta field', + inputSchema: { name: z.string() } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-without-meta'); + expect(result.tools[0]._meta).toBeUndefined(); + }); + + test('should validate tool names according to SEP specification', () => { + // Create a new server instance for this test + const testServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Spy on console.warn to verify warnings are logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Test valid tool names + testServer.registerTool( + 'valid-tool-name', + { + description: 'A valid tool name' + }, + async () => ({ content: [{ type: 'text' 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(); + }); +}); + +describe('resource()', () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ + test('should register resource with uri and readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].name).toBe('test'); + expect(result.resources[0].uri).toBe('test://resource'); + }); + + /*** + * Test: Update Resource with URI + */ + test('should update resource with uri', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Initial content' + } + ] + })); + + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource' + } + }, + ReadResourceResultSchema + ); + + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource' + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Update Resource Template + */ + test('should update resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource template + const resourceTemplate = mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Initial content' + } + ] + }) + ); + + // Update the resource template + resourceTemplate.update({ + callback: async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Updated content' + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource/123' + } + }, + ReadResourceResultSchema + ); + + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource/123' + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Resource List Changed Notification + */ + test('should send resource list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the resource while connected + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + }); + + /*** + * Test: Remove Resource and Send Notification + */ + test('should remove resource and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resources + const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] + })); + + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify both resources are registered + let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + + expect(result.resources).toHaveLength(2); + + expect(notifications).toHaveLength(0); + + // Remove a resource + resource1.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + + // Verify the resource was removed + result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].uri).toBe('test://resource2'); + }); + + /*** + * Test: Remove Resource Template and Send Notification + */ + test('should remove resource template and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register resource template + const resourceTemplate = mcpServer.resource( + 'template', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Template content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify template is registered + const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + + expect(result.resourceTemplates).toHaveLength(1); + expect(notifications).toHaveLength(0); + + // Remove the template + resourceTemplate.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + + // Verify the template was removed + const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + + expect(result2.resourceTemplates).toHaveLength(0); + }); + + /*** + * Test: Resource Registration with Metadata + */ + test('should register resource with metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + 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'); + }); + + /*** + * 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/123', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/templates/list' + }, + ListResourceTemplatesResultSchema + ); + + expect(result.resourceTemplates).toHaveLength(1); + expect(result.resourceTemplates[0].name).toBe('test'); + expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); + }); + + /*** + * Test: Resource Template with List Callback + */ + test('should register resource template with listCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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' + } + ] + }) + ); + + 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(2); + expect(result.resources[0].name).toBe('Resource 1'); + expect(result.resources[0].uri).toBe('test://resource/1'); + expect(result.resources[1].name).toBe('Resource 2'); + expect(result.resources[1].uri).toBe('test://resource/2'); + }); + + /*** + * Test: Template Variables to Read Callback + */ + test('should pass template variables to readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}/{id}', { + list: undefined + }), + async (uri, { category, id }) => ({ + contents: [ + { + uri: uri.href, + text: `Category: ${category}, ID: ${id}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource/books/123' + } + }, + 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: 'test://resource', + text: 'Test content' + } + ] + })); + + expect(() => { + mcpServer.resource('test2', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Resource Registration + */ + test('should allow registering multiple resources', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [ + { + uri: 'test://resource1', + text: 'Test content 1' + } + ] + })); + + // This should also succeed and not throw about request handlers + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [ + { + uri: 'test://resource2', + text: 'Test content 2' + } + ] + })); + }); + + /*** + * Test: Preventing Duplicate Resource Template Registration + */ + test('should prevent duplicate resource template registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ + contents: [ + { + 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/); + }); + + /*** + * Test: Graceful Resource Read Error Handling + */ + test('should handle resource read errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource('error-test', 'test://error', async () => { + throw new Error('Resource read failed'); + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://error' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource read failed/); + }); + + /*** + * 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' + }); + + mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://nonexistent' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); + }); + + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a resource template with a complete callback is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); + }); + + /*** + * Test: Resource Template Parameter Completion + */ + test('should support completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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)]); + + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: '' + } + } + }, + CompleteResultSchema + ); + + expect(result.completion.values).toEqual(['books', 'movies', 'music']); + expect(result.completion.total).toBe(3); + }); + + /*** + * Test: Filtered Resource Template Parameter Completion + */ + test('should support filtered completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: 'm' + } + } + }, + CompleteResultSchema + ); + + expect(result.completion.values).toEqual(['movies', 'music']); + expect(result.completion.total).toBe(2); + }); + + /*** + * Test: Pass Request ID to Resource Callback + */ + test('should pass requestId to resource callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { + receivedRequestId = extra.requestId; + return { + contents: [ + { + uri: 'test://resource', + text: `Received request ID: ${extra.requestId}` + } + ] + }; + }); + + 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' + } + }, + ReadResourceResultSchema + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining(`Received request ID:`), + uri: 'test://resource' + } + ]) + ); + }); +}); + +describe('prompt()', () => { + /*** + * Test: Zero-Argument Prompt Registration + */ + test('should register zero-argument prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'prompts/list' + }, + 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); + }; + + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Initial response' + } + } + ] + })); + + // Update the prompt + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call the prompt and verify we get the updated response + const result = await client.request( + { + method: 'prompts/get', + params: { + name: 'test' + } + }, + GetPromptResultSchema + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Updating Prompt with Schema + */ + test('should update prompt with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.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.connect(serverTransport)]); + + // Verify the schema was updated + const listResult = 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']); + + // 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(getResult.messages).toHaveLength(1); + expect(getResult.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated: test, value' + } + } + ]) + ); + + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); + }); + + /*** + * Test: Prompt List Changed Notification + */ + test('should send prompt list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + expect(notifications).toHaveLength(0); + + // Now update the prompt while connected + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + }); + + /*** + * Test: Remove Prompt and Send Notification + */ + test('should remove prompt and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompts + const prompt1 = mcpServer.prompt('prompt1', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Prompt 1 response' + } + } + ] + })); + + mcpServer.prompt('prompt2', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Prompt 2 response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Verify both prompts are registered + let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + + expect(result.prompts).toHaveLength(2); + expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); + + expect(notifications).toHaveLength(0); + + // Remove a prompt + prompt1.remove(); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + + // Verify the prompt was removed + result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('prompt2'); + }); + + /*** + * Test: Prompt Registration with Arguments Schema + */ + test('should register prompt with args schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test', + { + name: z.string(), + value: z.string() + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'prompts/list' + }, + 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' + }); + + 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 + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].description).toBe('Test description'); + }); + + /*** + * Test: Prompt Argument Validation + */ + test('should validate prompt args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test', + { + name: z.string(), + value: z.string().min(3) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'ab' // Too short + } + } + }, + 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' + } + } + ] + })); + + expect(() => { + mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }).toThrow(/already registered/); + }); + + /*** + * Test: Multiple Prompt Registration + */ + test('should allow registering multiple prompts', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.prompt('prompt1', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 1' + } + } + ] + })); + + // This should also succeed and not throw about request handlers + mcpServer.prompt('prompt2', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }); + + /*** + * Test: Prompt Registration with Arguments + */ + test('should allow registering prompts with arguments', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // This should succeed + mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` + } + } + ] + })); + }); + + /*** + * Test: Resources and Prompts with Completion Handlers + */ + test('should allow registering both resources and prompts with completion handlers', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Register a resource with completion + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + // Register a prompt with completion + mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` + } + } + ] + })); + }); + + /*** + * 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' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt('test-prompt', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } + } + ] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'nonexistent-prompt' + } + }, + GetPromptResultSchema + ) + ).rejects.toThrow(/Prompt nonexistent-prompt not found/); + }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a prompt with a completable argument is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', + { + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); + }); + + /*** + * Test: Prompt Argument Completion + */ + test('should support completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', + { + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: '' + } + } + }, + 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' + }); + + 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}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + } + } + }, + CompleteResultSchema + ); + + expect(result.completion.values).toEqual(['Alice']); + expect(result.completion.total).toBe(1); + }); + + /*** + * Test: Pass Request ID to Prompt Callback + */ + test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.prompt('request-id-test', async extra => { + receivedRequestId = extra.requestId; + return { + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Received request ID: ${extra.requestId}` + } + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'prompts/get', + params: { + name: 'request-id-test' + } + }, + GetPromptResultSchema + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: expect.stringContaining(`Received request ID:`) + } + } + ]) + ); + }); + + /*** + * Test: Resource Template Metadata Priority + */ + test('should prioritize individual resource metadata over template metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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 result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); + + expect(result.resources).toHaveLength(2); + + // Resource 1 should have its own metadata + expect(result.resources[0].name).toBe('Resource 1'); + expect(result.resources[0].description).toBe('Individual resource description'); + expect(result.resources[0].mimeType).toBe('text/plain'); + + // Resource 2 should inherit template metadata + expect(result.resources[1].name).toBe('Resource 2'); + expect(result.resources[1].description).toBe('Template description'); + expect(result.resources[1].mimeType).toBe('application/json'); + }); + + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test('should allow resource to override all template metadata fields', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.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 + } + ] + }) + }), + { + title: 'Template Name', + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); + + expect(result.resources).toHaveLength(1); + + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe('Overridden Name'); + expect(result.resources[0].description).toBe('Overridden description'); + expect(result.resources[0].mimeType).toBe('text/markdown'); + }); +}); + +describe('Tool title precedence', () => { + test('should follow correct title precedence: title → annotations.title → name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Tool 1: Only name + mcpServer.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 2: Name and title - title wins + expect( + getDisplayName({ + name: 'tool_name', + title: 'Tool Title' + }) + ).toBe('Tool Title'); + + // Test 3: Name and annotations.title - annotations.title wins + expect( + getDisplayName({ + name: 'tool_name', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 4: All three - title wins (correct precedence) + expect( + getDisplayName({ + name: 'tool_name', + title: 'Regular Title', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Regular Title'); + + // Test 5: Empty title should not be used + expect( + getDisplayName({ + name: 'tool_name', + title: '', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 6: Undefined vs null handling + expect( + getDisplayName({ + name: 'tool_name', + title: undefined, + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + }); + + test('should support resource template completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerResource( + 'test', + new ResourceTemplate('github://repos/{owner}/{repo}', { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.['owner'] === 'org1') { + return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); + } else if (context?.arguments?.['owner'] === 'org2') { + return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + } + return []; + } + } + }), + { + title: 'GitHub Repository', + description: 'Repository information' + }, + async () => ({ + contents: [ + { + uri: 'github://repos/test/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with microsoft owner + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'p' + }, + context: { + arguments: { + owner: 'org1' + } + } + } + }, + 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(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!` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Test with engineering department + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + }, + context: { + arguments: { + department: 'engineering' + } + } + } + }, + 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(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(result4.completion.values).toEqual(['Guest']); + }); +}); + +describe('elicitInput()', () => { + const checkAvailability = vi.fn().mockResolvedValue(false); + const findAlternatives = vi.fn().mockResolvedValue([]); + const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); + + let mcpServer: McpServer; + let client: Client; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0' + }); + + // Register the restaurant booking tool from README example + mcpServer.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?' + }, + flexibleDates: { + type: 'string', + title: 'Date flexibility', + description: 'How flexible are your dates?', + enum: ['next_day', 'same_week', 'next_week'], + enumNames: ['Next day', 'Same week', 'Next week'] + } + }, + required: ['checkAlternatives'] + } + }); + + if (result.action === 'accept' && result.content?.checkAlternatives) { + const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string); + return { + content: [ + { + type: 'text', + text: `Found these alternatives: ${alternatives.join(', ')}` + } + ] + }; + } + + return { + content: [ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ] + }; + } + + await makeBooking(restaurant, date, partySize); + return { + content: [ + { + type: 'text', + text: `Booked table for ${partySize} at ${restaurant} on ${date}` + } + ] + }; + } + ); + + // Create client with elicitation capability + client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + }); + + test('should successfully elicit additional information', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); + + // Set up client to accept alternative date checking + client.setRequestHandler(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' + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' + } + ]); + }); + + test('should handle user declining to elicitation request', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to reject alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'accept', + content: { + checkAlternatives: false + } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); + + test('should handle user cancelling the elicitation', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'cancel' + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); + + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); + }); +}); + +describe('Tools with union and intersection schemas', () => { + test('should support union schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('email'), email: z.string().email() }), + z.object({ type: z.literal('phone'), phone: z.string() }) + ]); + + server.registerTool('contact', { inputSchema: unionSchema }, async args => { + if (args.type === 'email') { + return { + content: [{ type: 'text', text: `Email contact: ${args.email}` }] + }; + } else { + return { + content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] + }; + } + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const emailResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'email', + email: 'test@example.com' + } + }); + + expect(emailResult.content).toEqual([ + { + type: 'text', + text: 'Email contact: test@example.com' + } + ]); + + const phoneResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'phone', + phone: '+1234567890' + } + }); + + expect(phoneResult.content).toEqual([ + { + type: 'text', + text: 'Phone contact: +1234567890' + } + ]); + }); + + test('should support intersection schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const baseSchema = z.object({ id: z.string() }); + const extendedSchema = z.object({ name: z.string(), age: z.number() }); + const intersectionSchema = z.intersection(baseSchema, extendedSchema); + + server.registerTool('user', { inputSchema: intersectionSchema }, async args => { + return { + content: [ + { + type: 'text', + text: `User: ${args.id}, ${args.name}, ${args.age} years old` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'user', + arguments: { + id: '123', + name: 'John Doe', + age: 30 + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'User: 123, John Doe, 30 years old' + } + ]); + }); + + test('should support complex nested schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const schema = z.object({ + items: z.array( + z.union([ + z.object({ type: z.literal('text'), content: z.string() }), + z.object({ type: z.literal('number'), value: z.number() }) + ]) + ) + }); + + server.registerTool('process', { inputSchema: schema }, async args => { + const processed = args.items.map(item => { + if (item.type === 'text') { + return item.content.toUpperCase(); + } else { + return item.value * 2; + } + }); + return { + content: [ + { + type: 'text', + text: `Processed: ${processed.join(', ')}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'process', + arguments: { + items: [ + { type: 'text', content: 'hello' }, + { type: 'number', value: 5 }, + { type: 'text', content: 'world' } + ] + } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: HELLO, 10, WORLD' + } + ]); + }); + + test('should validate union schema inputs correctly', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + const unionSchema = z.union([ + z.object({ type: z.literal('a'), value: z.string() }), + z.object({ type: z.literal('b'), value: z.number() }) + ]); + + server.registerTool('union-test', { inputSchema: unionSchema }, async () => { + return { + content: [{ type: 'text', text: 'Success' }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const invalidTypeResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }); + + expect(invalidTypeResult.isError).toBe(true); + expect(invalidTypeResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); + + const invalidDiscriminatorResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'c', + value: 'test' + } + }); + + expect(invalidDiscriminatorResult.isError).toBe(true); + expect(invalidDiscriminatorResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); + }); +}); diff --git a/src/server/v3/sse.v3.test.ts b/src/server/v3/sse.v3.test.ts new file mode 100644 index 000000000..be19726e8 --- /dev/null +++ b/src/server/v3/sse.v3.test.ts @@ -0,0 +1,711 @@ +import http from 'http'; +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 * as z from 'zod/v3'; +import { CallToolResult, JSONRPCMessage } from '../../types.js'; + +const createMockResponse = () => { + const res = { + writeHead: vi.fn().mockReturnThis(), + write: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + end: vi.fn().mockReturnThis() + }; + + return res as unknown as Mocked; +}; + +const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { + const mockReq = { + headers, + body: body ? body : undefined, + auth: { + token: 'test-token' + }, + 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); + } + if (event === 'error') { + mockListener(new Error('test')); + } + if (event === 'end') { + mockListener(); + } + if (event === 'close') { + setTimeout(listener, 100); + } + return mockReq; + }), + 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'); + + const events: string[] = []; + const decoder = new TextDecoder(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (value) { + events.push(decoder.decode(value)); + } + } + } finally { + reader.releaseLock(); + } + + return events; +} + +/** + * Helper to send JSON-RPC request + */ +async function sendSsePostRequest( + baseUrl: URL, + message: JSONRPCMessage | JSONRPCMessage[], + sessionId?: string, + extraHeaders?: Record +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...extraHeaders + }; + + if (sessionId) { + baseUrl.searchParams.set('sessionId', sessionId); + } + + return fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify(message) + }); +} + +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: {} + }, + + id: 'init-1' + } as JSONRPCMessage); + + expect(response.status).toBe(202); + + const text = await readAllSSEEvents(response); + + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } + + 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; + + 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`); + }); + + 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; + + 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` + ); + }); + + 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(); + + 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 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(); + + 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 handle the root path endpoint "/"', async () => { + const mockRes = createMockResponse(); + const endpoint = '/'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; + + await transport.start(); + + 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 an empty string endpoint ""', async () => { + const mockRes = createMockResponse(); + const endpoint = ''; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; + + await transport.start(); + + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); + }); + + /** + * 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' + } + }, + id: 'call-1' + }; + + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + + expect(response.status).toBe(202); + + 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`); + }); + }); + + 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); + }); + + 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}`); + }); + + 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 + }); + 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 + }, + { + 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 = vi.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(() => { + vi.clearAllMocks(); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + host: 'localhost:3000', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); + }); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + origin: 'http://evil.com', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); + }); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); + }); + + 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(); + + const mockReq = createMockRequest({ + headers: { + 'content-type': 'text/plain' + } + }); + const mockHandleRes = createMockResponse(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); + }); + }); + + 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(); + + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + + // 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'); + }); + }); + + 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' }); + + 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(); + + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); + + 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(); + + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); + + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); + }); + }); +}); diff --git a/src/server/v3/streamableHttp.v3.test.ts b/src/server/v3/streamableHttp.v3.test.ts new file mode 100644 index 000000000..524069080 --- /dev/null +++ b/src/server/v3/streamableHttp.v3.test.ts @@ -0,0 +1,2151 @@ +import { createServer, type Server, IncomingMessage, ServerResponse } from 'node:http'; +import { createServer as netCreateServer, AddressInfo } from 'node:net'; +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 * as z from 'zod/v3'; +import { AuthInfo } from '../auth/types.js'; + +async function getFreePort() { + return new Promise(res => { + const srv = netCreateServer(); + srv.listen(0, () => { + const address = srv.address()!; + if (typeof address === 'string') { + throw new Error('Unexpected address type: ' + typeof address); + } + const port = (address as AddressInfo).port; + srv.close(_err => res(port)); + }); + }); +} + +/** + * Test server configuration for StreamableHTTPServerTransport tests + */ +interface TestServerConfig { + sessionIdGenerator: (() => string) | undefined; + enableJsonResponse?: boolean; + customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; + 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 }; +} + +/** + * Helper to stop test server + */ +async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { + // First close the transport to ensure all SSE streams are closed + await transport.close(); + + // Close the server without waiting indefinitely + server.close(); +} + +/** + * Common test messages + */ +const TEST_MESSAGES = { + initialize: { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26', + capabilities: {} + }, + + id: 'init-1' + } as JSONRPCMessage, + + toolsList: { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'tools-1' + } as JSONRPCMessage +}; + +/** + * Helper to extract text from SSE response + * Note: Can only be called once per response stream. For multiple reads, + * get the reader manually and read multiple times. + */ +async function readSSEEvent(response: Response): Promise { + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + return new TextDecoder().decode(value); +} + +/** + * Helper to send JSON-RPC request + */ +async function sendPostRequest( + baseUrl: URL, + message: JSONRPCMessage | JSONRPCMessage[], + sessionId?: string, + extraHeaders?: Record +): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...extraHeaders + }; + + if (sessionId) { + headers['mcp-session-id'] = sessionId; + // After initialization, include the protocol version header + headers['mcp-protocol-version'] = '2025-03-26'; + } + + return fetch(baseUrl, { + method: 'POST', + headers, + body: JSON.stringify(message) + }); +} + +function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { + expect(data).toMatchObject({ + jsonrpc: '2.0', + error: expect.objectContaining({ + code: expectedCode, + message: expect.stringMatching(expectedMessagePattern) + }) + }); +} + +describe('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' + } + ]; + + 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' + }); + }); + + it('should call a tool and return the result', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + } + ] + }, + id: 'call-1' + }); + }); + + /*** + * Test: Tool With Request Info + */ + it('should pass request info to tool callback', async () => { + sessionId = await initializeServer(); + + 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' + } + }, + 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) + } + }); + }); + + 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'); + + 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' + } + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); + + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }); + }); + + it('should not close GET SSE stream after sending multiple server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); + + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }; + + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); + + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('First notification'); + expect(done).toBe(false); // Stream should still be open + }); + + it('should reject second SSE stream for the same session', async () => { + sessionId = await initializeServer(); + + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(firstStream.status).toBe(200); + + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + // 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 reject GET requests without Accept: text/event-stream header', async () => { + sessionId = await initializeServer(); + + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); + }); + + it('should reject POST requests without proper Accept header', async () => { + sessionId = await initializeServer(); + + // Try POST without Accept: text/event-stream + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', // Missing text/event-stream + 'mcp-session-id': sessionId + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); + }); + + it('should reject unsupported Content-Type', async () => { + sessionId = await initializeServer(); + + // Try POST with text/plain Content-Type + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: 'This is plain text' + }); + + expect(response.status).toBe(415); + const errorData = await response.json(); + expectErrorResponse(errorData, -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 handle batch request messages with SSE stream for responses', async () => { + sessionId = await initializeServer(); + + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it('should properly handle invalid JSON data', async () => { + sessionId = await initializeServer(); + + // Send invalid JSON + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: 'This is not valid JSON' + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -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'); + + 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 send response messages to the connection that sent the request', async () => { + sessionId = await initializeServer(); + + const message1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'req-1' + }; + + const message2: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'Connection2' } + }, + id: 'req-2' + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it('should keep stream open after sending server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + // Send several server-initiated notifications + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }); + + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Second notification' } + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it('should properly handle DELETE requests and close session', async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-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(); + + // 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' + } + }); + + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); + }); + + describe('protocol version header validation', () => { + it('should accept requests with matching protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + + expect(response.status).toBe(200); + }); + + it('should accept requests without protocol version header', async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with unsupported protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + + 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) + }); + + // 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' + } + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + + it('should handle protocol version validation for DELETE requests', async () => { + sessionId = await initializeServer(); + + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'invalid-version' + } + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + }); +}); + +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; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } + + it('should call a tool with authInfo', async () => { + sessionId = await initializeServer(); + + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: true } + }, + id: 'call-1' + }; + + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); + expect(response.status).toBe(200); + + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); + + const eventData = JSON.parse(dataLine!.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' + }); + }); +}); + +// 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); + + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should return JSON response for a single request', async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'json-req-1' + }; + + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const result = await response.json(); + expect(result).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }), + id: 'json-req-1' + }); + }); + + it('should return JSON response for batch requests', async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } + ]; + + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const results = await response.json(); + 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() + }); + + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should accept pre-parsed request body', async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-1' + }; + + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + // Empty body - we're testing pre-parsed body + body: '' + }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); + }); + + it('should handle pre-parsed batch messages', async () => { + parsedBody = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } + ]; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: '' // Empty as we're using pre-parsed + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); + }); + + it('should prefer pre-parsed body over request body', async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-wins' + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Ignored' } }, + id: 'ignored-id' + }) + }); + + expect(response.status).toBe(200); + + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); +}); + +// Test resumability support +describe('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); + } + } + 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(); + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); + + it('should store and include event IDs in server SSE messages', async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); + + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification with event ID' } + }; + + // Send the notification via transport + await transport.send(notification); + + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); + + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); + + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('_GET_stream')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); + + it('should store and replay MCP server tool notifications', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + 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 + } + }); + + 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: '); + }); +}); + +// 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; + }); + + afterEach(async () => { + await stopTestServer({ server, transport }); + }); + + it('should operate without session ID validation', async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get('mcp-session-id')).toBeNull(); + + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + + expect(toolsResponse.status).toBe(200); + }); + + it('should handle POST requests with various session IDs in stateless mode', async () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'random-id-1' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) + }); + expect(response1.status).toBe(200); + + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'different-id-2' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) + }); + expect(response2.status).toBe(200); + }); + + it('should reject second SSE stream even in stateless mode', async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time + + // Initialize the server first + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-03-26' + } + }); + 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 onsessionclosed callback +describe('StreamableHTTPServerTransport onsessionclosed callback', () => { + it('should call onsessionclosed callback when session is closed via DELETE', async () => { + const mockCallback = vi.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // Clean up + tempServer.close(); + }); + + it('should not call onsessionclosed callback when not provided', async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); + }); + + it('should not call onsessionclosed callback for invalid session DELETE', async () => { + const mockCallback = vi.fn(); + + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); + + // Clean up + tempServer.close(); + }); + + it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { + const mockCallback = vi.fn(); + + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const server1 = result1.server; + const url1 = result1.baseUrl; + + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); + + const server2 = result2.server; + const url2 = result2.baseUrl; + + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get('mcp-session-id'); + + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get('mcp-session-id'); + + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); + + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId1 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); + + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId2 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + 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; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { + const capturedSessionId: string[] = []; + + // Create server with sync onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + capturedSessionId.push(sessionId); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + expect(capturedSessionId).toEqual([tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should support async onsessionclosed callback', async () => { + const closureOrder: string[] = []; + + // Create server with async onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (sessionId: string) => { + closureOrder.push('async-close-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + closureOrder.push('async-close-end'); + closureOrder.push(sessionId); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); + + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); + + // Clean up + tempServer.close(); + }); + + it('should propagate errors from async onsessioninitialized callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create server with async onsessioninitialized callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (_sessionId: string) => { + throw new Error('Async initialization error'); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize should fail when callback throws + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(400); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it('should propagate errors from async onsessionclosed callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create server with async onsessionclosed callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (_sessionId: string) => { + throw new Error('Async closure error'); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // DELETE should fail when callback throws + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(500); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); + }); + + it('should handle both async callbacks together', async () => { + const events: string[] = []; + + // Create server with both async callbacks + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`initialized:${sessionId}`); + }, + onsessionclosed: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`closed:${sessionId}`); + } + }); + + const tempServer = result.server; + const tempUrl = result.baseUrl; + + // Initialize to trigger first callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`initialized:${tempSessionId}`); + + // DELETE to trigger second callback + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`closed:${tempSessionId}`); + expect(events).toHaveLength(2); + + // Clean up + tempServer.close(); + }); +}); + +// Test DNS rebinding protection +describe('StreamableHTTPServerTransport DNS rebinding protection', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with disallowed host headers', async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain('Invalid Host header:'); + }); + + it('should reject GET requests with disallowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream' + } + }); + + expect(response.status).toBe(403); + }); + }); + + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3000' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); + }); + + it('should reject requests with disallowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + 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 + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Host: 'evil.com', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); + }); + }); + + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + 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); + }); + }); +}); + +/** + * Helper to create test server with DNS rebinding protection options + */ +async function createTestServerWithDnsProtection(config: { + sessionIdGenerator: (() => string) | undefined; + allowedHosts?: string[]; + allowedOrigins?: string[]; + enableDnsRebindingProtection?: boolean; +}): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; +}> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + const port = await getFreePort(); + + if (config.allowedHosts) { + config.allowedHosts = config.allowedHosts.map(host => { + if (host.includes(':')) { + return host; + } + return `localhost:${port}`; + }); + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + allowedHosts: config.allowedHosts, + allowedOrigins: config.allowedOrigins, + enableDnsRebindingProtection: config.enableDnsRebindingProtection + }); + + await mcpServer.connect(transport); + + const httpServer = createServer(async (req, res) => { + if (req.method === 'POST') { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', async () => { + const parsedBody = JSON.parse(body); + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); + }); + } else { + await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); + } + }); + + await new Promise(resolve => { + httpServer.listen(port, () => resolve()); + }); + + const serverUrl = new URL(`http://localhost:${port}/`); + + return { + server: httpServer, + transport, + mcpServer, + baseUrl: serverUrl + }; +} diff --git a/src/server/v3/title.v3.test.ts b/src/server/v3/title.v3.test.ts new file mode 100644 index 000000000..2d99d5316 --- /dev/null +++ b/src/server/v3/title.v3.test.ts @@ -0,0 +1,224 @@ +import { Server } from '../index.js'; +import { Client } from '../../client/index.js'; +import { InMemoryTransport } from '../../inMemory.js'; +import * as z from 'zod/v3'; +import { McpServer, ResourceTemplate } from '../mcp.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'); + }); + + it('should work with tools without title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register tool without title + server.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' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const tools = await client.listTools(); + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0].name).toBe('test-tool'); + expect(tools.tools[0].title).toBeUndefined(); + expect(tools.tools[0].description).toBe('A test tool'); + }); + + it('should work with prompts that have title using update', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register prompt with title by updating after creation + const prompt = server.prompt('test-prompt', 'A test prompt', async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'test' } }] + })); + prompt.update({ title: 'Test Prompt Display Name' }); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe('test-prompt'); + expect(prompts.prompts[0].title).toBe('Test Prompt Display Name'); + expect(prompts.prompts[0].description).toBe('A test prompt'); + }); + + it('should work with prompts using registerPrompt', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register prompt with title using registerPrompt + server.registerPrompt( + 'test-prompt', + { + title: 'Test Prompt Display Name', + description: 'A test prompt', + argsSchema: { input: z.string() } + }, + async ({ input }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `test: ${input}` } + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const prompts = await client.listPrompts(); + expect(prompts.prompts).toHaveLength(1); + expect(prompts.prompts[0].name).toBe('test-prompt'); + expect(prompts.prompts[0].title).toBe('Test Prompt Display Name'); + expect(prompts.prompts[0].description).toBe('A test prompt'); + expect(prompts.prompts[0].arguments).toHaveLength(1); + }); + + it('should work with resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register resource with title using registerResource + server.registerResource( + 'test-resource', + 'https://example.com/test', + { + title: 'Test Resource Display Name', + description: 'A test resource', + mimeType: 'text/plain' + }, + async () => ({ + contents: [ + { + uri: 'https://example.com/test', + text: 'test content' + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(1); + expect(resources.resources[0].name).toBe('test-resource'); + expect(resources.resources[0].title).toBe('Test Resource Display Name'); + expect(resources.resources[0].description).toBe('A test resource'); + expect(resources.resources[0].mimeType).toBe('text/plain'); + }); + + it('should work with dynamic resources using registerResource', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); + + // Register dynamic resource with title using registerResource + server.registerResource( + 'user-profile', + new ResourceTemplate('users://{userId}/profile', { list: undefined }), + { + title: 'User Profile', + description: 'User profile information' + }, + async (uri, { userId }, _extra) => ({ + contents: [ + { + uri: uri.href, + text: `Profile data for user ${userId}` + } + ] + }) + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.server.connect(serverTransport); + await client.connect(clientTransport); + + const resourceTemplates = await client.listResourceTemplates(); + expect(resourceTemplates.resourceTemplates).toHaveLength(1); + expect(resourceTemplates.resourceTemplates[0].name).toBe('user-profile'); + expect(resourceTemplates.resourceTemplates[0].title).toBe('User Profile'); + expect(resourceTemplates.resourceTemplates[0].description).toBe('User profile information'); + expect(resourceTemplates.resourceTemplates[0].uriTemplate).toBe('users://{userId}/profile'); + + // Test reading the resource + const readResult = await client.readResource({ uri: 'users://123/profile' }); + expect(readResult.contents).toHaveLength(1); + expect(readResult.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Profile data for user 123'), + uri: 'users://123/profile' + } + ]) + ); + }); + + it('should support serverInfo with title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const server = new Server( + { + name: 'test-server', + version: '1.0.0', + title: 'Test Server Display Name' + }, + { capabilities: {} } + ); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const serverInfo = client.getServerVersion(); + expect(serverInfo?.name).toBe('test-server'); + expect(serverInfo?.version).toBe('1.0.0'); + expect(serverInfo?.title).toBe('Test Server Display Name'); + }); +}); diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts new file mode 100644 index 000000000..3590a8cee --- /dev/null +++ b/src/server/zod-compat.ts @@ -0,0 +1,190 @@ +// 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 +interface ZodV3Internal { + _def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + shape?: Record | (() => Record); +} + +interface ZodV4Internal { + _zod?: { + def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + }; +} + +// --- 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; + +export type ObjectOutput = SchemaOutput; + +/** + * 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; +} diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts new file mode 100644 index 000000000..27b5a48dd --- /dev/null +++ b/src/server/zod-json-schema-compat.ts @@ -0,0 +1,45 @@ +// 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 { AnyObjectSchema, isZ4Schema } from './zod-compat.js'; +import { zodToJsonSchema } from '../_vendor/zod-to-json-schema/index.js'; + +type JsonSchema = Record; + +// Options accepted by call sites; we map them appropriately +export 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; +} diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 819b33086..a00740d1b 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod/v4'; /** * Reusable URL validation that disallows javascript: scheme diff --git a/src/shared/protocol-transport-handling.test.ts b/src/shared/protocol-transport-handling.test.ts index 83181494f..b463d6db4 100644 --- a/src/shared/protocol-transport-handling.test.ts +++ b/src/shared/protocol-transport-handling.test.ts @@ -2,7 +2,7 @@ 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.ts b/src/shared/protocol.ts index 48cad896f..541a87ede 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,4 +1,30 @@ -import { ZodLiteral, ZodObject, ZodType, z } from 'zod'; +import { AnySchema, AnyObjectSchema, SchemaOutput, getObjectShape, safeParse, isZ4Schema } from '../server/zod-compat.js'; + +// Helper interfaces for accessing Zod internal properties (same as in zod-compat.ts) +interface ZodV3Internal { + _def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + shape?: Record | (() => Record); + value?: unknown; +} + +interface ZodV4Internal { + _zod?: { + def?: { + typeName?: string; + value?: unknown; + values?: unknown[]; + shape?: Record | (() => Record); + description?: string; + }; + }; + value?: unknown; +} import { CancelledNotificationSchema, ClientCapabilities, @@ -152,7 +178,7 @@ export type RequestHandlerExtra>(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; + sendRequest: (request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; }; /** @@ -489,7 +515,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 +580,13 @@ export abstract class Protocol); + } } catch (error) { reject(error); } @@ -638,19 +669,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 +706,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)); + }); } /** @@ -714,3 +746,55 @@ export function mergeCapabilities = 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. From 6669217015888c4fd04c10727ea1bcf0af6293d8 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:29:38 -0800 Subject: [PATCH 02/13] chore: use zod v4 methods for types and auth --- src/client/v3/index.v3.test.ts | 38 +++++--- src/server/v3/index.v3.test.ts | 42 ++++++--- src/shared/auth.ts | 161 ++++++++++++++++----------------- src/types.ts | 123 ++++++++++++------------- 4 files changed, 189 insertions(+), 175 deletions(-) diff --git a/src/client/v3/index.v3.test.ts b/src/client/v3/index.v3.test.ts index 23a79cef8..803866748 100644 --- a/src/client/v3/index.v3.test.ts +++ b/src/client/v3/index.v3.test.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client } from '../index.js'; import * as z from 'zod/v3'; +import { AnyObjectSchema } from '../../server/zod-compat.js'; import { RequestSchema, NotificationSchema, @@ -601,39 +602,48 @@ test('should allow setRequestHandler for declared elicitation capability', () => * Test that custom request/notification/result schemas can be used with the Client class. */ test('should typecheck', () => { - const GetWeatherRequestSchema = RequestSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetWeatherRequestSchema = (RequestSchema as unknown as z.ZodObject).extend({ method: z.literal('weather/get'), params: z.object({ city: z.string() }) - }); + }) as AnyObjectSchema; - const GetForecastRequestSchema = RequestSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetForecastRequestSchema = (RequestSchema as unknown as z.ZodObject).extend({ method: z.literal('weather/forecast'), params: z.object({ city: z.string(), days: z.number() }) - }); + }) as AnyObjectSchema; - const WeatherForecastNotificationSchema = NotificationSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherForecastNotificationSchema = (NotificationSchema as unknown as z.ZodObject).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({ + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z.ZodObject).or( + GetForecastRequestSchema as unknown as z.ZodObject + ) as AnyObjectSchema; + const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherResultSchema = (ResultSchema as unknown as z.ZodObject).extend({ temperature: z.number(), conditions: z.string() - }); + }) as AnyObjectSchema; - type WeatherRequest = z.infer; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type InferSchema = T extends z.ZodType ? Output : never; + type WeatherRequest = InferSchema; + type WeatherNotification = InferSchema; + type WeatherResult = InferSchema; // Create a typed Client for weather data const weatherClient = new Client( diff --git a/src/server/v3/index.v3.test.ts b/src/server/v3/index.v3.test.ts index a6c02b208..2068e2a74 100644 --- a/src/server/v3/index.v3.test.ts +++ b/src/server/v3/index.v3.test.ts @@ -19,6 +19,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS } from '../../types.js'; import { Server } from '../index.js'; +import { AnyObjectSchema } from '../zod-compat.js'; test('should accept latest protocol version', async () => { let sendPromiseResolve: (value: unknown) => void; @@ -632,39 +633,48 @@ test('should only allow setRequestHandler for declared capabilities', () => { Test that custom request/notification/result schemas can be used with the Server class. */ test('should typecheck', () => { - const GetWeatherRequestSchema = RequestSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetWeatherRequestSchema = (RequestSchema as unknown as z.ZodObject).extend({ method: z.literal('weather/get'), params: z.object({ city: z.string() }) - }); + }) as AnyObjectSchema; - const GetForecastRequestSchema = RequestSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const GetForecastRequestSchema = (RequestSchema as unknown as z.ZodObject).extend({ method: z.literal('weather/forecast'), params: z.object({ city: z.string(), days: z.number() }) - }); + }) as AnyObjectSchema; - const WeatherForecastNotificationSchema = NotificationSchema.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherForecastNotificationSchema = (NotificationSchema as unknown as z.ZodObject).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({ + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z.ZodObject).or( + GetForecastRequestSchema as unknown as z.ZodObject + ) as AnyObjectSchema; + const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherResultSchema = (ResultSchema as unknown as z.ZodObject).extend({ temperature: z.number(), conditions: z.string() - }); + }) as AnyObjectSchema; - type WeatherRequest = z.infer; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type InferSchema = T extends z.ZodType ? Output : never; + type WeatherRequest = InferSchema; + type WeatherNotification = InferSchema; + type WeatherResult = InferSchema; // Create a typed Server for weather data const weatherServer = new Server( @@ -691,7 +701,9 @@ test('should typecheck', () => { }); weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => { - console.log(`Weather alert: ${notification.params.message}`); + // Type assertion needed for v3/v4 schema mixing + const params = notification.params as { message: string; severity: 'warning' | 'watch' }; + console.log(`Weather alert: ${params.message}`); }); }); diff --git a/src/shared/auth.ts b/src/shared/auth.ts index a00740d1b..7ac90c011 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -28,105 +28,100 @@ 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() +}); /** * 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() +}); /** * 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/types.ts b/src/types.ts index 9544660f0..5b968be6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; @@ -264,12 +254,14 @@ 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); +}); /** * 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. @@ -436,7 +428,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 +523,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 +547,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 +577,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 +702,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 +717,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. @@ -931,7 +931,9 @@ 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. */ @@ -953,9 +955,6 @@ export const ToolSchema = BaseMetadataSchema.extend({ 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()) }) .optional(), @@ -969,7 +968,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. @@ -1445,34 +1444,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 */ @@ -1769,11 +1768,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 */ From b89200a365dd7604ccd59b315142b24ca7c7f0ab Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:33:32 -0800 Subject: [PATCH 03/13] chore: use RemovePassthrough on remaining spec params --- src/spec.types.test.ts | 58 +++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 2417e6b1d..34a8d5e1e 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -76,88 +76,97 @@ 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: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { + CompleteRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.CompleteRequestParams) => { sdk = spec; spec = sdk; }, - ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + ElicitRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.ElicitRequestParams) => { sdk = spec; spec = sdk; }, - PaginatedRequestParams: (sdk: SDKTypes.PaginatedRequestParams, spec: SpecTypes.PaginatedRequestParams) => { + PaginatedRequestParams: (sdk: RemovePassthrough, spec: SpecTypes.PaginatedRequestParams) => { sdk = spec; spec = sdk; }, - CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + CancelledNotification: (sdk: RemovePassthrough>, spec: SpecTypes.CancelledNotification) => { sdk = spec; spec = sdk; }, @@ -201,7 +210,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 +218,7 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CompleteRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CompleteRequest) => { + CompleteRequest: (sdk: RemovePassthrough>, spec: SpecTypes.CompleteRequest) => { sdk = spec; spec = sdk; }, @@ -520,12 +529,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; From 1e0b11e148a89e29af9b05ed928371d11039896e Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:38:48 -0800 Subject: [PATCH 04/13] chore: resolve remaining as any casts --- src/client/v3/index.v3.test.ts | 1 + src/server/completable.ts | 7 +-- src/server/mcp.ts | 45 ++++++----------- src/server/v3/index.v3.test.ts | 1 + src/server/zod-compat.ts | 90 ++++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 35 deletions(-) diff --git a/src/client/v3/index.v3.test.ts b/src/client/v3/index.v3.test.ts index 803866748..c601e4097 100644 --- a/src/client/v3/index.v3.test.ts +++ b/src/client/v3/index.v3.test.ts @@ -630,6 +630,7 @@ test('should typecheck', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z.ZodObject).or( + // eslint-disable-next-line @typescript-eslint/no-explicit-any GetForecastRequestSchema as unknown as z.ZodObject ) as AnyObjectSchema; const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; diff --git a/src/server/completable.ts b/src/server/completable.ts index 65f306584..be067ac55 100644 --- a/src/server/completable.ts +++ b/src/server/completable.ts @@ -21,10 +21,7 @@ export type CompletableSchema = T & { * 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 { +export function completable(schema: T, complete: CompleteCallback): CompletableSchema { Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, { value: { complete } as CompletableMeta, enumerable: false, @@ -45,7 +42,7 @@ export function isCompletable(schema: unknown): schema is CompletableSchema(schema: T): CompleteCallback | undefined { - const meta = (schema as any)[COMPLETABLE_SYMBOL] as CompletableMeta | undefined; + const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta })[COMPLETABLE_SYMBOL]; return meta?.complete as CompleteCallback | undefined; } diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 7e0975d11..b057673bc 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -7,9 +7,12 @@ import { ShapeOutput, normalizeObjectSchema, safeParseAsync, - isZ4Schema, getObjectShape, - objectFromShape + objectFromShape, + getParseErrorMessage, + getSchemaDescription, + isSchemaOptional, + getLiteralValue } from './zod-compat.js'; import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; import { @@ -169,7 +172,7 @@ export class McpServer { if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Input validation error: Invalid arguments for tool ${request.params.name}: ${(parseResult as any).error.message}` + `Input validation error: Invalid arguments for tool ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } @@ -195,7 +198,7 @@ export class McpServer { if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Output validation error: Invalid structured content for tool ${request.params.name}: ${(parseResult as any).error.message}` + `Output validation error: Invalid structured content for tool ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } } @@ -441,7 +444,7 @@ export class McpServer { if (!parseResult.success) { throw new McpError( ErrorCode.InvalidParams, - `Invalid arguments for prompt ${request.params.name}: ${(parseResult as any).error.message}` + `Invalid arguments for prompt ${request.params.name}: ${getParseErrorMessage(parseResult.error)}` ); } @@ -1079,10 +1082,7 @@ export class ResourceTemplate { * - Both fields are optional but typically one should be provided */ export type ToolCallback = Args extends ZodRawShapeCompat - ? ( - args: ShapeOutput, - extra: RequestHandlerExtra - ) => CallToolResult | Promise + ? (args: ShapeOutput, extra: RequestHandlerExtra) => CallToolResult | Promise : Args extends AnySchema ? ( args: SchemaOutput, @@ -1228,10 +1228,7 @@ export type RegisteredResourceTemplate = { type PromptArgsRawShape = ZodRawShapeCompat; export type PromptCallback = Args extends PromptArgsRawShape - ? ( - args: ShapeOutput, - extra: RequestHandlerExtra - ) => GetPromptResult | Promise + ? (args: ShapeOutput, extra: RequestHandlerExtra) => GetPromptResult | Promise : (extra: RequestHandlerExtra) => GetPromptResult | Promise; export type RegisteredPrompt = { @@ -1258,9 +1255,9 @@ function promptArgumentsFromSchema(schema: AnyObjectSchema): PromptArgument[] { if (!shape) return []; return Object.entries(shape).map(([name, field]): PromptArgument => { // Get description - works for both v3 and v4 - const description = (field as any).description ?? (field as any)._def?.description; + const description = getSchemaDescription(field); // Check if optional - works for both v3 and v4 - const isOptional = (field as any).isOptional?.() ?? (field as any)._def?.typeName === 'ZodOptional'; + const isOptional = isSchemaOptional(field); return { name, description, @@ -1277,21 +1274,9 @@ function getMethodValue(schema: AnyObjectSchema): string { } // Extract literal value - works for both v3 and v4 - const v4Def = isZ4Schema(methodSchema) ? (methodSchema as any)._zod?.def : undefined; - const legacyDef = (methodSchema as any)._def; - - const candidates = [ - v4Def?.value, - legacyDef?.value, - Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, - Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, - (methodSchema as any).value - ]; - - for (const candidate of candidates) { - if (typeof candidate === 'string') { - return candidate; - } + const value = getLiteralValue(methodSchema); + if (typeof value === 'string') { + return value; } throw new Error('Schema method literal must be a string'); diff --git a/src/server/v3/index.v3.test.ts b/src/server/v3/index.v3.test.ts index 2068e2a74..1e6e0d3d5 100644 --- a/src/server/v3/index.v3.test.ts +++ b/src/server/v3/index.v3.test.ts @@ -661,6 +661,7 @@ test('should typecheck', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z.ZodObject).or( + // eslint-disable-next-line @typescript-eslint/no-explicit-any GetForecastRequestSchema as unknown as z.ZodObject ) as AnyObjectSchema; const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index 3590a8cee..df29798c2 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -188,3 +188,93 @@ export function normalizeObjectSchema(schema: AnySchema | ZodRawShapeCompat | un 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; +} From 3f69eaebe1d9af5acd137148d73eb159d8be332b Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:42:19 -0800 Subject: [PATCH 05/13] chore: attempt to address node 18 test failure --- src/integration-tests/taskResumability.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index ca5895e58..d3f54c9d5 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -193,8 +193,14 @@ describe('Transport resumability', () => { } ); - // Wait for some notifications to arrive (not all) - shorter wait time - await new Promise(resolve => setTimeout(resolve, 20)); + // Fix for node 18 test failures, allow some time for notifications to arrive + const maxWaitTime = 2000; // 2 seconds max wait + const pollInterval = 10; // Check every 10ms + const startTime = Date.now(); + while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) { + // Wait for some notifications to arrive (not all) - shorter wait time + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } // Verify we received some notifications and lastEventId was updated expect(notifications.length).toBeGreaterThan(0); From 4e79d6f39f5673ee0273193129e58d49719dc42b Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:46:12 -0800 Subject: [PATCH 06/13] chore: remove plans --- ZOD_V4_COMPATIBILITY_APPROACH.md | 678 ------------------------------- 1 file changed, 678 deletions(-) delete mode 100644 ZOD_V4_COMPATIBILITY_APPROACH.md diff --git a/ZOD_V4_COMPATIBILITY_APPROACH.md b/ZOD_V4_COMPATIBILITY_APPROACH.md deleted file mode 100644 index d2d9e63d2..000000000 --- a/ZOD_V4_COMPATIBILITY_APPROACH.md +++ /dev/null @@ -1,678 +0,0 @@ -# Zod v4 Compatibility Approach - -This document outlines the comprehensive approach taken to make the MCP TypeScript SDK compatible with both Zod v3 and Zod v4, allowing users to use either version seamlessly. - -## Overview - -The primary goal was to support both Zod v3 (`^3.25`) and Zod v4 (`^4.0`) without breaking existing code. This required: - -1. **Unified type system** that accepts both v3 and v4 schemas -2. **Runtime detection** to determine which Zod version is being used -3. **Compatibility layer** for operations that differ between versions -4. **Vendored dependencies** for v3-specific functionality -5. **Symbol-based metadata** instead of class inheritance for extensibility -6. **Comprehensive test coverage** for both versions - -## Key Design Decisions - -### 1. Dual Import Strategy - -Zod v4 introduced subpath exports (`zod/v3`, `zod/v4/core`, `zod/v4-mini`) that allow importing specific versions. We leverage this to: - -- Import types from both versions: `import type * as z3 from 'zod/v3'` and `import type * as z4 from 'zod/v4/core'` -- Import runtime implementations: `import * as z3rt from 'zod/v3'` and `import * as z4mini from 'zod/v4-mini'` -- Use v4 Mini as the default for new schemas (smaller bundle size) - -### 2. No Version Mixing - -We enforce that within a single schema shape (e.g., an object's properties), all schemas must be from the same version. Mixed versions throw an error at runtime. - -### 3. Backward Compatibility First - -The SDK maintains full backward compatibility with Zod v3 while adding v4 support. Existing code using v3 continues to work without changes. - -## Core Compatibility Files - -### `src/server/zod-compat.ts` - -This is the **foundation** of the compatibility layer, providing unified types and runtime helpers. - -#### Unified Types - -```typescript -export type AnySchema = z3.ZodTypeAny | z4.$ZodType; -export type AnyObjectSchema = z3.AnyZodObject | z4.$ZodObject; -export type ZodRawShapeCompat = Record; -``` - -These types accept schemas from either version, allowing the rest of the codebase to work with both. - -#### Type Inference Helpers - -```typescript -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; -``` - -These conditional types correctly infer input/output types based on which version is being used. - -#### Runtime Detection - -```typescript -export function isZ4Schema(s: AnySchema): s is z4.$ZodType { - // Present on Zod 4 (Classic & Mini) schemas; absent on Zod 3 - return !!(s as any)?._zod; -} -``` - -Zod v4 schemas have a `_zod` property that v3 schemas lack. This is the key to runtime version detection. - -#### Schema Construction - -```typescript -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.'); -} -``` - -This function: - -- Detects which version all schemas in a shape belong to -- Constructs the appropriate object schema using the correct version's API -- Throws if versions are mixed (enforcing our "no mixing" rule) - -#### Unified Parsing - -```typescript -export function safeParse(schema: S, data: unknown): { success: true; data: SchemaOutput } | { success: false; error: unknown } { - if (isZ4Schema(schema)) { - // Mini exposes top-level safeParse - return z4mini.safeParse(schema as z4.$ZodType, data) as any; - } - return (schema as z3.ZodTypeAny).safeParse(data) as any; -} -``` - -The parsing API differs between versions: - -- **v3**: Instance method `schema.safeParse(data)` -- **v4**: Top-level function `z4mini.safeParse(schema, data)` - -Our wrapper abstracts this difference. - -### `src/server/zod-json-schema-compat.ts` - -JSON Schema conversion differs significantly between versions, requiring a compatibility layer. - -#### The Challenge - -- **Zod v3**: Uses external library `zod-to-json-schema` (vendored in `_vendor/`) -- **Zod v4**: Has built-in `toJSONSchema` method on schemas - -#### Solution - -```typescript -export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { - if (isZ4Schema(schema)) { - // v4 branch — use Mini's built-in toJSONSchema - return z4mini.toJSONSchema(schema as z4.$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 any - ) as JsonSchema; -} -``` - -#### Option Mapping - -The options API differs between versions: - -- **v3**: `strictUnions`, `pipeStrategy`, `target` (with values like `'jsonSchema7'`) -- **v4**: `target` (with values like `'draft-7'`), `io` (instead of `pipeStrategy`) - -We map between these formats: - -```typescript -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 -} -``` - -### `src/_vendor/zod-to-json-schema/` - -We **vendored** the `zod-to-json-schema` library for v3 compatibility. This means: - -1. **No external dependency** on `zod-to-json-schema` (removed from `package.json`) -2. **Full control** over the implementation -3. **Version-specific imports** - all files import from `'zod/v3'` explicitly -4. **Isolation** - v3 conversion logic is completely separate from v4 - -#### Why Vendor? - -- Zod v4 has built-in JSON Schema conversion, so `zod-to-json-schema` is only needed for v3 -- Vendoring ensures we can fix bugs or make modifications without waiting for upstream -- Reduces dependency surface area -- Allows us to pin to a specific version that works with our codebase - -#### Structure - -The vendored code is a complete copy of `zod-to-json-schema` with: - -- All imports changed to `'zod/v3'` -- All type references updated to use v3 types -- Original LICENSE and README preserved - -### `src/server/completable.ts` - -The completable feature allows schemas to provide autocompletion suggestions. This required a major refactor. - -#### The Problem - -**Before (v3-only):** - -```typescript -export class Completable extends ZodType<...> { - _parse(input: ParseInput): ParseReturnType { - return this._def.type._parse({...}); - } - unwrap() { - return this._def.type; - } -} -``` - -This approach: - -- Extended Zod's base class (not possible in v4 due to API changes) -- Relied on Zod internals (`_def`, `_parse`, etc.) that changed in v4 -- Required deep integration with Zod's type system - -#### The Solution: Symbol-Based Metadata - -**After (v3 + v4 compatible):** - -```typescript -export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable'); - -export type CompletableSchema = T & { - [COMPLETABLE_SYMBOL]: CompletableMeta; -}; - -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; -} -``` - -This approach: - -- **No inheritance** - works with any schema type from either version -- **Non-intrusive** - doesn't modify parsing behavior -- **Version-agnostic** - uses standard JavaScript symbols -- **Backward compatible** - provides `unwrapCompletable()` helper for code that called `.unwrap()` - -#### Detection - -```typescript -export function isCompletable(schema: unknown): schema is CompletableSchema { - return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object); -} - -export function getCompleter(schema: T): CompleteCallback | undefined { - const meta = (schema as any)[COMPLETABLE_SYMBOL] as CompletableMeta | undefined; - return meta?.complete as CompleteCallback | undefined; -} -``` - -### `src/server/mcp.ts` - -The MCP server implementation was updated to use the compatibility layer throughout. - -#### Key Changes - -1. **Imports updated:** - -```typescript -// Before -import { z, ZodRawShape, AnyZodObject, ZodTypeAny } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; - -// After -import { AnySchema, AnyObjectSchema, ZodRawShapeCompat, ObjectOutput, normalizeObjectSchema, safeParseAsync, safeParse, isZ4Schema } from './zod-compat.js'; -import { toJsonSchemaCompat } from './zod-json-schema-compat.js'; -``` - -2. **Schema normalization:** - -```typescript -// Before -inputSchema: tool.inputSchema - ? zodToJsonSchema(tool.inputSchema, {...}) - : EMPTY_OBJECT_JSON_SCHEMA - -// After -inputSchema: (() => { - const obj = normalizeObjectSchema(tool.inputSchema); - return obj - ? (toJsonSchemaCompat(obj, {...}) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA; -})() -``` - -The `normalizeObjectSchema` function handles both: - -- Already-constructed object schemas (v3 or v4) -- Raw shapes that need to be wrapped into object schemas - -3. **Parsing updated:** - -```typescript -// Before -const parseResult = await tool.inputSchema.safeParseAsync(request.params.arguments); - -// After -const inputObj = normalizeObjectSchema(tool.inputSchema) as AnyObjectSchema; -const parseResult = await safeParseAsync(inputObj, request.params.arguments); -``` - -4. **Completable detection:** - -```typescript -// Before -const field = prompt.argsSchema.shape[request.params.argument.name]; -if (!(field instanceof Completable)) { - return EMPTY_COMPLETION_RESULT; -} -const def: CompletableDef = field._def; -const suggestions = await def.complete(...); - -// After -const promptShape = getObjectShape(prompt.argsSchema); -const field = promptShape?.[request.params.argument.name]; -if (!isCompletable(field)) { - return EMPTY_COMPLETION_RESULT; -} -const completer = getCompleter(field); -const suggestions = await completer(...); -``` - -5. **Shape extraction:** - -```typescript -function getObjectShape(schema: AnyObjectSchema | undefined): Record | undefined { - if (!schema) return undefined; - - // Zod v3 exposes `.shape`; Zod v4 keeps the shape on `_zod.def.shape` - const rawShape = (schema as any).shape ?? (isZ4Schema(schema) ? (schema as any)._zod?.def?.shape : undefined); - - if (!rawShape) return undefined; - - if (typeof rawShape === 'function') { - try { - return rawShape(); - } catch { - return undefined; - } - } - - return rawShape as Record; -} -``` - -This handles the difference in how object shapes are accessed: - -- **v3**: `schema.shape` (direct property) -- **v4**: `schema._zod.def.shape` (nested, may be a function) - -### `src/shared/protocol.ts` - -The protocol layer handles request/response parsing and needed updates for schema handling. - -#### Key Changes - -1. **Type updates:** - -```typescript -// Before -sendRequest: >(request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; - -// After -sendRequest: (request: SendRequestT, resultSchema: U, options?: RequestOptions) => Promise>; -``` - -2. **Request handler signatures:** - -```typescript -// Before -setRequestHandler< - T extends ZodObject<{ method: ZodLiteral }> ->( - requestSchema: T, - handler: (request: z.infer, extra: ...) => ... -): void { - const method = requestSchema.shape.method.value; - this._requestHandlers.set(method, (request, extra) => { - return Promise.resolve(handler(requestSchema.parse(request), extra)); - }); -} - -// After -setRequestHandler( - requestSchema: T, - handler: (request: SchemaOutput, extra: ...) => ... -): void { - const method = getMethodLiteral(requestSchema); - this._requestHandlers.set(method, (request, extra) => { - const parsed = parseWithCompat(requestSchema, request) as SchemaOutput; - return Promise.resolve(handler(parsed, extra)); - }); -} -``` - -3. **Helper functions:** - -```typescript -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; -} - -function getLiteralValue(schema: AnySchema): unknown { - const v4Def = isZ4Schema(schema) ? (schema as any)._zod?.def : undefined; - const legacyDef = (schema as any)._def; - - const candidates = [v4Def?.value, legacyDef?.value, Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, (schema as any).value]; - - for (const candidate of candidates) { - if (typeof candidate !== 'undefined') { - return candidate; - } - } - - return undefined; -} -``` - -These helpers extract literal values from schemas, handling differences in how v3 and v4 store this information. - -### `src/types.ts` - -The types file was updated to use Zod v4 by default: - -```typescript -// Before -import * as z from 'zod'; - -// After -import * as z from 'zod/v4'; -``` - -This means: - -- **New code** defaults to v4 (smaller bundle, better performance) -- **Existing code** using v3 continues to work via the compatibility layer -- The SDK's internal types use v4, but accept v3 schemas from users - -## Package.json Changes - -### Dependencies - -```json -{ - "dependencies": { - "zod": "^3.25 || ^4.0" - }, - "peerDependencies": { - "zod": "^3.25 || ^4.0" - } -} -``` - -Key points: - -- **Range supports both versions** - `^3.25 || ^4.0` allows either -- **Peer dependency** - users must provide their own Zod installation -- **Removed `zod-to-json-schema`** - now vendored for v3 support - -## Testing Strategy - -### Dual Test Suites - -We maintain **separate test files** for v3 and v4: - -- **v4 tests**: `src/server/mcp.test.ts`, `src/server/index.test.ts`, etc. -- **v3 tests**: `src/server/v3/mcp.v3.test.ts`, `src/server/v3/index.v3.test.ts`, etc. - -### Test File Pattern - -Each v3 test file: - -1. Imports from `'zod/v3'` explicitly -2. Uses v3-specific APIs (e.g., `.passthrough()` instead of `.looseObject()`) -3. Tests the same functionality as v4 tests -4. Verifies compatibility layer works correctly - -Example from `src/server/v3/mcp.v3.test.ts`: - -```typescript -import * as z from 'zod/v3'; - -// Uses v3-specific syntax -const RequestSchemaV3Base = z.object({ - method: z.string(), - params: z.optional(z.object({ _meta: z.optional(z.object({})) }).passthrough()) -}); -``` - -### Why Separate Tests? - -1. **API differences** - v3 and v4 have different APIs that need testing -2. **Type safety** - TypeScript can't always infer which version is being used -3. **Documentation** - Shows users how to use each version -4. **Regression prevention** - Ensures changes don't break either version - -## Migration Guide for Re-application - -When rebasing and re-applying these changes, follow this order: - -### 1. Create Compatibility Layer - -1. **Create `src/server/zod-compat.ts`** - - Copy the unified types - - Add runtime detection functions - - Add schema construction helpers - - Add unified parsing functions - -2. **Create `src/server/zod-json-schema-compat.ts`** - - Add JSON Schema conversion wrapper - - Map options between versions - - Handle both conversion paths - -### 2. Vendor zod-to-json-schema - -1. **Copy `zod-to-json-schema` to `src/_vendor/zod-to-json-schema/`** -2. **Update all imports** in vendored files to use `'zod/v3'` -3. **Update type references** to use v3 types -4. **Preserve LICENSE and README** - -### 3. Update Completable - -1. **Refactor `src/server/completable.ts`** - - Remove class inheritance - - Add symbol-based metadata - - Add detection helpers - - Add unwrap helper for backward compat - -### 4. Update Core Files - -1. **Update `src/server/mcp.ts`** - - Replace Zod imports with compat imports - - Update schema normalization calls - - Update parsing calls - - Update completable detection - - Add shape extraction helpers - -2. **Update `src/shared/protocol.ts`** - - Replace Zod types with compat types - - Update request handler signatures - - Add helper functions for method/literal extraction - - Update parsing calls - -3. **Update `src/types.ts`** - - Change import to `'zod/v4'` - - Update schema definitions to use v4 APIs - -### 5. Update Package.json - -1. **Update zod dependency** to `"^3.25 || ^4.0"` -2. **Add zod as peerDependency** -3. **Remove `zod-to-json-schema` dependency** - -### 6. Add Tests - -1. **Create v3 test files** in `src/server/v3/`, `src/client/v3/`, etc. -2. **Update existing tests** to use v4 imports -3. **Ensure both test suites pass** - -### 7. Update Examples - -1. **Update example files** to use `'zod/v4'` (or show both options) -2. **Add comments** showing v3 alternative where relevant - -## Key Challenges and Solutions - -### Challenge 1: Type System Differences - -**Problem**: v3 uses `ZodTypeAny`, v4 uses `$ZodType`. Type inference differs. - -**Solution**: Conditional types that check which version and use appropriate inference: - -```typescript -export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends z4.$ZodType ? z4.output : never; -``` - -### Challenge 2: API Differences - -**Problem**: Parsing, schema construction, and shape access all differ. - -**Solution**: Wrapper functions that detect version and call appropriate API: - -```typescript -export function safeParse(schema: S, data: unknown) { - if (isZ4Schema(schema)) { - return z4mini.safeParse(schema, data); - } - return schema.safeParse(data); -} -``` - -### Challenge 3: JSON Schema Conversion - -**Problem**: v3 needs external library, v4 has built-in method. - -**Solution**: Compatibility wrapper that routes to correct converter: - -```typescript -export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts) { - if (isZ4Schema(schema)) { - return z4mini.toJSONSchema(schema, {...}); - } - return zodToJsonSchema(schema, {...}); -} -``` - -### Challenge 4: Extensibility (Completable) - -**Problem**: Class inheritance doesn't work across versions. - -**Solution**: Symbol-based metadata that works with any schema: - -```typescript -export const COMPLETABLE_SYMBOL = Symbol.for('mcp.completable'); -export function completable(schema: T, complete: ...) { - Object.defineProperty(schema, COMPLETABLE_SYMBOL, {...}); - return schema; -} -``` - -### Challenge 5: Shape Access - -**Problem**: v3 has `schema.shape`, v4 has `schema._zod.def.shape` (may be function). - -**Solution**: Helper that checks both locations and handles functions: - -```typescript -function getObjectShape(schema: AnyObjectSchema) { - const rawShape = (schema as any).shape ?? (isZ4Schema(schema) ? (schema as any)._zod?.def?.shape : undefined); - if (typeof rawShape === 'function') { - return rawShape(); - } - return rawShape; -} -``` - -## Best Practices - -1. **Always use compat types** - Never import directly from `'zod'` in core code -2. **Normalize schemas early** - Use `normalizeObjectSchema()` when accepting schemas -3. **Use unified parsing** - Always use `safeParse()` / `safeParseAsync()` from compat -4. **Check version at runtime** - Use `isZ4Schema()` when needed, but prefer compat functions -5. **Don't mix versions** - Enforce single-version shapes -6. **Test both versions** - Maintain test coverage for v3 and v4 - -## Future Considerations - -1. **Zod v4 Classic** - Currently using Mini; may need Classic support later -2. **Deprecation path** - Eventually may deprecate v3 support -3. **Performance** - Monitor bundle size impact of dual support -4. **Type improvements** - May be able to improve type inference with newer TypeScript features - -## Summary - -The Zod v4 compatibility approach uses: - -- **Unified types** (`zod-compat.ts`) to abstract version differences -- **Runtime detection** (`isZ4Schema()`) to route to correct APIs -- **Compatibility wrappers** for parsing, JSON Schema conversion, etc. -- **Vendored dependencies** for v3-specific functionality -- **Symbol-based metadata** for extensibility without inheritance -- **Dual test suites** to ensure both versions work correctly - -This approach maintains full backward compatibility while adding v4 support, allowing users to migrate at their own pace. From dcfcca8fd2d556ec42734434a03a0df15926c730 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:48:32 -0800 Subject: [PATCH 07/13] chore: consolidate multiple ZodV4Internal and ZodV3Internal defs --- src/client/index.ts | 36 +++++++++--------------------------- src/server/zod-compat.ts | 6 ++++-- src/shared/protocol.ts | 28 +--------------------------- 3 files changed, 14 insertions(+), 56 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 7c0f0d6ba..36d5a6959 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -42,35 +42,17 @@ import { } from '../types.js'; import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js'; import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js'; -import { AnyObjectSchema, SchemaOutput, getObjectShape, isZ4Schema, safeParse, type AnySchema } from '../server/zod-compat.js'; +import { + AnyObjectSchema, + SchemaOutput, + getObjectShape, + isZ4Schema, + safeParse, + type ZodV3Internal, + type ZodV4Internal +} from '../server/zod-compat.js'; import type { RequestHandlerExtra } from '../shared/protocol.js'; -// Helper interfaces for accessing Zod internal properties (same as in zod-compat.ts and protocol.ts) -interface ZodV3Internal { - _def?: { - typeName?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - shape?: Record | (() => Record); - value?: unknown; -} - -interface ZodV4Internal { - _zod?: { - def?: { - typeName?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - }; - value?: unknown; -} - /** * Elicitation default application helper. Applies defaults to the data based on the schema. * diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index df29798c2..631c906f8 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -16,7 +16,7 @@ export type ZodRawShapeCompat = Record; // --- Internal property access helpers --- // These types help us safely access internal properties that differ between v3 and v4 -interface ZodV3Internal { +export interface ZodV3Internal { _def?: { typeName?: string; value?: unknown; @@ -25,9 +25,10 @@ interface ZodV3Internal { description?: string; }; shape?: Record | (() => Record); + value?: unknown; } -interface ZodV4Internal { +export interface ZodV4Internal { _zod?: { def?: { typeName?: string; @@ -37,6 +38,7 @@ interface ZodV4Internal { description?: string; }; }; + value?: unknown; } // --- Type inference helpers --- diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 541a87ede..bfe726c00 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,30 +1,4 @@ -import { AnySchema, AnyObjectSchema, SchemaOutput, getObjectShape, safeParse, isZ4Schema } from '../server/zod-compat.js'; - -// Helper interfaces for accessing Zod internal properties (same as in zod-compat.ts) -interface ZodV3Internal { - _def?: { - typeName?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - shape?: Record | (() => Record); - value?: unknown; -} - -interface ZodV4Internal { - _zod?: { - def?: { - typeName?: string; - value?: unknown; - values?: unknown[]; - shape?: Record | (() => Record); - description?: string; - }; - }; - value?: unknown; -} +import { AnySchema, AnyObjectSchema, SchemaOutput, getObjectShape, safeParse, isZ4Schema, type ZodV3Internal, type ZodV4Internal } from '../server/zod-compat.js'; import { CancelledNotificationSchema, ClientCapabilities, From e3b70273bfa137d9eb107b1d3172ec4c726247f9 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Mon, 17 Nov 2025 15:51:18 -0800 Subject: [PATCH 08/13] chore: formatting --- src/shared/protocol.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index bfe726c00..75a52c0eb 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,4 +1,13 @@ -import { AnySchema, AnyObjectSchema, SchemaOutput, getObjectShape, safeParse, isZ4Schema, type ZodV3Internal, type ZodV4Internal } from '../server/zod-compat.js'; +import { + AnySchema, + AnyObjectSchema, + SchemaOutput, + getObjectShape, + safeParse, + isZ4Schema, + type ZodV3Internal, + type ZodV4Internal +} from '../server/zod-compat.js'; import { CancelledNotificationSchema, ClientCapabilities, From 6e4cfa24fe1dddf7d917798bcf0b20ea26b777b6 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Tue, 18 Nov 2025 09:54:27 -0800 Subject: [PATCH 09/13] chore: remove _vendor zod-to-json-schema --- .prettierignore | 3 - eslint.config.mjs | 13 - package-lock.json | 12 +- package.json | 3 +- src/_vendor/zod-to-json-schema/LICENSE | 15 - src/_vendor/zod-to-json-schema/Options.ts | 80 ---- src/_vendor/zod-to-json-schema/README.md | 3 - src/_vendor/zod-to-json-schema/Refs.ts | 45 -- .../zod-to-json-schema/errorMessages.ts | 31 -- src/_vendor/zod-to-json-schema/index.ts | 38 -- src/_vendor/zod-to-json-schema/parseDef.ts | 258 ----------- src/_vendor/zod-to-json-schema/parsers/any.ts | 5 - .../zod-to-json-schema/parsers/array.ts | 36 -- .../zod-to-json-schema/parsers/bigint.ts | 60 --- .../zod-to-json-schema/parsers/boolean.ts | 9 - .../zod-to-json-schema/parsers/branded.ts | 7 - .../zod-to-json-schema/parsers/catch.ts | 7 - .../zod-to-json-schema/parsers/date.ts | 83 ---- .../zod-to-json-schema/parsers/default.ts | 10 - .../zod-to-json-schema/parsers/effects.ts | 11 - .../zod-to-json-schema/parsers/enum.ts | 13 - .../parsers/intersection.ts | 64 --- .../zod-to-json-schema/parsers/literal.ts | 37 -- src/_vendor/zod-to-json-schema/parsers/map.ts | 42 -- .../zod-to-json-schema/parsers/nativeEnum.ts | 27 -- .../zod-to-json-schema/parsers/never.ts | 9 - .../zod-to-json-schema/parsers/null.ts | 16 - .../zod-to-json-schema/parsers/nullable.ts | 49 --- .../zod-to-json-schema/parsers/number.ts | 62 --- .../zod-to-json-schema/parsers/object.ts | 76 ---- .../zod-to-json-schema/parsers/optional.ts | 28 -- .../zod-to-json-schema/parsers/pipeline.ts | 28 -- .../zod-to-json-schema/parsers/promise.ts | 7 - .../zod-to-json-schema/parsers/readonly.ts | 7 - .../zod-to-json-schema/parsers/record.ts | 73 ---- src/_vendor/zod-to-json-schema/parsers/set.ts | 36 -- .../zod-to-json-schema/parsers/string.ts | 400 ------------------ .../zod-to-json-schema/parsers/tuple.ts | 54 --- .../zod-to-json-schema/parsers/undefined.ts | 9 - .../zod-to-json-schema/parsers/union.ts | 119 ------ .../zod-to-json-schema/parsers/unknown.ts | 5 - src/_vendor/zod-to-json-schema/util.ts | 11 - .../zod-to-json-schema/zodToJsonSchema.ts | 120 ------ src/server/zod-json-schema-compat.ts | 2 +- 44 files changed, 14 insertions(+), 2009 deletions(-) delete mode 100644 src/_vendor/zod-to-json-schema/LICENSE delete mode 100644 src/_vendor/zod-to-json-schema/Options.ts delete mode 100644 src/_vendor/zod-to-json-schema/README.md delete mode 100644 src/_vendor/zod-to-json-schema/Refs.ts delete mode 100644 src/_vendor/zod-to-json-schema/errorMessages.ts delete mode 100644 src/_vendor/zod-to-json-schema/index.ts delete mode 100644 src/_vendor/zod-to-json-schema/parseDef.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/any.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/array.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/bigint.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/boolean.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/branded.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/catch.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/date.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/default.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/effects.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/enum.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/intersection.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/literal.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/map.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/never.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/null.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/nullable.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/number.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/object.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/optional.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/pipeline.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/promise.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/readonly.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/record.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/set.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/string.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/tuple.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/undefined.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/union.ts delete mode 100644 src/_vendor/zod-to-json-schema/parsers/unknown.ts delete mode 100644 src/_vendor/zod-to-json-schema/util.ts delete mode 100644 src/_vendor/zod-to-json-schema/zodToJsonSchema.ts diff --git a/.prettierignore b/.prettierignore index 88de1d1e6..ae37f91c7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,3 @@ pnpm-lock.yaml # Ignore generated files src/spec.types.ts - -# Ignore vendor files -src/_vendor/ \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f3e840e3..5fd27f3ab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,18 +25,5 @@ export default tseslint.config( 'no-console': 'error' } }, - { - files: ['src/_vendor/**/*.ts'], - rules: { - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-unused-vars': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-empty-object-type': 'off', - '@typescript-eslint/no-wrapper-object-types': 'off', - 'no-fallthrough': 'off', - 'no-case-declarations': 'off', - 'no-useless-escape': 'off' - } - }, eslintConfigPrettier ); diff --git a/package-lock.json b/package-lock.json index c5cd38f98..7c6b790ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,8 @@ "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", @@ -4612,6 +4613,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz", + "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index 9155047c2..c479ddd79 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0" + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", diff --git a/src/_vendor/zod-to-json-schema/LICENSE b/src/_vendor/zod-to-json-schema/LICENSE deleted file mode 100644 index a4690a1b6..000000000 --- a/src/_vendor/zod-to-json-schema/LICENSE +++ /dev/null @@ -1,15 +0,0 @@ -ISC License - -Copyright (c) 2020, Stefan Terdell - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/src/_vendor/zod-to-json-schema/Options.ts b/src/_vendor/zod-to-json-schema/Options.ts deleted file mode 100644 index 338254dda..000000000 --- a/src/_vendor/zod-to-json-schema/Options.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { ZodSchema, ZodTypeDef } from 'zod/v3'; -import { Refs, Seen } from './Refs.js'; -import { JsonSchema7Type } from './parseDef.js'; - -export type Targets = 'jsonSchema7' | 'jsonSchema2019-09' | 'openApi3'; - -export type DateStrategy = 'format:date-time' | 'format:date' | 'string' | 'integer'; - -export const ignoreOverride = Symbol('Let zodToJsonSchema decide on which parser to use'); - -export type Options = { - name: string | undefined; - $refStrategy: 'root' | 'relative' | 'none' | 'seen' | 'extract-to-root'; - basePath: string[]; - effectStrategy: 'input' | 'any'; - pipeStrategy: 'input' | 'output' | 'all'; - dateStrategy: DateStrategy | DateStrategy[]; - mapStrategy: 'entries' | 'record'; - removeAdditionalStrategy: 'passthrough' | 'strict'; - nullableStrategy: 'from-target' | 'property'; - target: Target; - strictUnions: boolean; - definitionPath: string; - definitions: Record; - errorMessages: boolean; - markdownDescription: boolean; - patternStrategy: 'escape' | 'preserve'; - applyRegexFlags: boolean; - emailStrategy: 'format:email' | 'format:idn-email' | 'pattern:zod'; - base64Strategy: 'format:binary' | 'contentEncoding:base64' | 'pattern:zod'; - nameStrategy: 'ref' | 'duplicate-ref' | 'title'; - override?: ( - def: ZodTypeDef, - refs: Refs, - seen: Seen | undefined, - forceResolution?: boolean, - ) => JsonSchema7Type | undefined | typeof ignoreOverride; - openaiStrictMode?: boolean; -}; - -const defaultOptions: Omit = { - name: undefined, - $refStrategy: 'root', - effectStrategy: 'input', - pipeStrategy: 'all', - dateStrategy: 'format:date-time', - mapStrategy: 'entries', - nullableStrategy: 'from-target', - removeAdditionalStrategy: 'passthrough', - definitionPath: 'definitions', - target: 'jsonSchema7', - strictUnions: false, - errorMessages: false, - markdownDescription: false, - patternStrategy: 'escape', - applyRegexFlags: false, - emailStrategy: 'format:email', - base64Strategy: 'contentEncoding:base64', - nameStrategy: 'ref', -}; - -export const getDefaultOptions = ( - options: Partial> | string | undefined, -) => { - // We need to add `definitions` here as we may mutate it - return ( - typeof options === 'string' ? - { - ...defaultOptions, - basePath: ['#'], - definitions: {}, - name: options, - } - : { - ...defaultOptions, - basePath: ['#'], - definitions: {}, - ...options, - }) as Options; -}; diff --git a/src/_vendor/zod-to-json-schema/README.md b/src/_vendor/zod-to-json-schema/README.md deleted file mode 100644 index ffb351242..000000000 --- a/src/_vendor/zod-to-json-schema/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Zod to Json Schema - -Vendored version of https://github.com/StefanTerdell/zod-to-json-schema that has been updated to generate JSON Schemas that are compatible with OpenAI's [strict mode](https://platform.openai.com/docs/guides/structured-outputs/supported-schemas) diff --git a/src/_vendor/zod-to-json-schema/Refs.ts b/src/_vendor/zod-to-json-schema/Refs.ts deleted file mode 100644 index e8b290d12..000000000 --- a/src/_vendor/zod-to-json-schema/Refs.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @ts-nocheck -import type { ZodTypeDef } from 'zod/v3'; -import { getDefaultOptions, Options, Targets } from './Options.js'; -import { JsonSchema7Type } from './parseDef.js'; -import { zodDef } from './util.js'; - -export type Refs = { - seen: Map; - /** - * Set of all the `$ref`s we created, e.g. `Set(['#/$defs/ui'])` - * this notable does not include any `definitions` that were - * explicitly given as an option. - */ - seenRefs: Set; - currentPath: string[]; - propertyPath: string[] | undefined; -} & Options; - -export type Seen = { - def: ZodTypeDef; - path: string[]; - jsonSchema: JsonSchema7Type | undefined; -}; - -export const getRefs = (options?: string | Partial>): Refs => { - const _options = getDefaultOptions(options); - const currentPath = _options.name !== undefined ? [..._options.basePath, _options.definitionPath, _options.name] : _options.basePath; - return { - ..._options, - currentPath: currentPath, - propertyPath: undefined, - seenRefs: new Set(), - seen: new Map( - Object.entries(_options.definitions).map(([name, def]) => [ - zodDef(def), - { - def: zodDef(def), - path: [..._options.basePath, _options.definitionPath, name], - // Resolution of references will be forced even though seen, so it's ok that the schema is undefined here for now. - jsonSchema: undefined - } - ]) - ) - }; -}; diff --git a/src/_vendor/zod-to-json-schema/errorMessages.ts b/src/_vendor/zod-to-json-schema/errorMessages.ts deleted file mode 100644 index 8ce868e2e..000000000 --- a/src/_vendor/zod-to-json-schema/errorMessages.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { JsonSchema7TypeUnion } from './parseDef.js'; -import { Refs } from './Refs.js'; - -export type ErrorMessages = Partial< - Omit<{ [key in keyof T]: string }, OmitProperties | 'type' | 'errorMessages'> ->; - -export function addErrorMessage }>( - res: T, - key: keyof T, - errorMessage: string | undefined, - refs: Refs, -) { - if (!refs?.errorMessages) return; - if (errorMessage) { - res.errorMessage = { - ...res.errorMessage, - [key]: errorMessage, - }; - } -} - -export function setResponseValueAndErrors< - Json7Type extends JsonSchema7TypeUnion & { - errorMessage?: ErrorMessages; - }, - Key extends keyof Omit, ->(res: Json7Type, key: Key, value: Json7Type[Key], errorMessage: string | undefined, refs: Refs) { - res[key] = value; - addErrorMessage(res, key, errorMessage, refs); -} diff --git a/src/_vendor/zod-to-json-schema/index.ts b/src/_vendor/zod-to-json-schema/index.ts deleted file mode 100644 index 12847e27f..000000000 --- a/src/_vendor/zod-to-json-schema/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// @ts-nocheck -export * from './Options.js'; -export * from './Refs.js'; -export * from './errorMessages.js'; -export * from './parseDef.js'; -export * from './parsers/any.js'; -export * from './parsers/array.js'; -export * from './parsers/bigint.js'; -export * from './parsers/boolean.js'; -export * from './parsers/branded.js'; -export * from './parsers/catch.js'; -export * from './parsers/date.js'; -export * from './parsers/default.js'; -export * from './parsers/effects.js'; -export * from './parsers/enum.js'; -export * from './parsers/intersection.js'; -export * from './parsers/literal.js'; -export * from './parsers/map.js'; -export * from './parsers/nativeEnum.js'; -export * from './parsers/never.js'; -export * from './parsers/null.js'; -export * from './parsers/nullable.js'; -export * from './parsers/number.js'; -export * from './parsers/object.js'; -export * from './parsers/optional.js'; -export * from './parsers/pipeline.js'; -export * from './parsers/promise.js'; -export * from './parsers/readonly.js'; -export * from './parsers/record.js'; -export * from './parsers/set.js'; -export * from './parsers/string.js'; -export * from './parsers/tuple.js'; -export * from './parsers/undefined.js'; -export * from './parsers/union.js'; -export * from './parsers/unknown.js'; -export * from './zodToJsonSchema.js'; -import { zodToJsonSchema } from './zodToJsonSchema.js'; -export default zodToJsonSchema; diff --git a/src/_vendor/zod-to-json-schema/parseDef.ts b/src/_vendor/zod-to-json-schema/parseDef.ts deleted file mode 100644 index 113b68555..000000000 --- a/src/_vendor/zod-to-json-schema/parseDef.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { ZodFirstPartyTypeKind, ZodTypeDef } from 'zod/v3'; -import { JsonSchema7AnyType, parseAnyDef } from './parsers/any.js'; -import { JsonSchema7ArrayType, parseArrayDef } from './parsers/array.js'; -import { JsonSchema7BigintType, parseBigintDef } from './parsers/bigint.js'; -import { JsonSchema7BooleanType, parseBooleanDef } from './parsers/boolean.js'; -import { parseBrandedDef } from './parsers/branded.js'; -import { parseCatchDef } from './parsers/catch.js'; -import { JsonSchema7DateType, parseDateDef } from './parsers/date.js'; -import { parseDefaultDef } from './parsers/default.js'; -import { parseEffectsDef } from './parsers/effects.js'; -import { JsonSchema7EnumType, parseEnumDef } from './parsers/enum.js'; -import { JsonSchema7AllOfType, parseIntersectionDef } from './parsers/intersection.js'; -import { JsonSchema7LiteralType, parseLiteralDef } from './parsers/literal.js'; -import { JsonSchema7MapType, parseMapDef } from './parsers/map.js'; -import { JsonSchema7NativeEnumType, parseNativeEnumDef } from './parsers/nativeEnum.js'; -import { JsonSchema7NeverType, parseNeverDef } from './parsers/never.js'; -import { JsonSchema7NullType, parseNullDef } from './parsers/null.js'; -import { JsonSchema7NullableType, parseNullableDef } from './parsers/nullable.js'; -import { JsonSchema7NumberType, parseNumberDef } from './parsers/number.js'; -import { JsonSchema7ObjectType, parseObjectDef } from './parsers/object.js'; -import { parseOptionalDef } from './parsers/optional.js'; -import { parsePipelineDef } from './parsers/pipeline.js'; -import { parsePromiseDef } from './parsers/promise.js'; -import { JsonSchema7RecordType, parseRecordDef } from './parsers/record.js'; -import { JsonSchema7SetType, parseSetDef } from './parsers/set.js'; -import { JsonSchema7StringType, parseStringDef } from './parsers/string.js'; -import { JsonSchema7TupleType, parseTupleDef } from './parsers/tuple.js'; -import { JsonSchema7UndefinedType, parseUndefinedDef } from './parsers/undefined.js'; -import { JsonSchema7UnionType, parseUnionDef } from './parsers/union.js'; -import { JsonSchema7UnknownType, parseUnknownDef } from './parsers/unknown.js'; -import { Refs, Seen } from './Refs.js'; -import { parseReadonlyDef } from './parsers/readonly.js'; -import { ignoreOverride } from './Options.js'; - -type JsonSchema7RefType = { $ref: string }; -type JsonSchema7Meta = { - title?: string; - default?: any; - description?: string; - markdownDescription?: string; -}; - -export type JsonSchema7TypeUnion = - | JsonSchema7StringType - | JsonSchema7ArrayType - | JsonSchema7NumberType - | JsonSchema7BigintType - | JsonSchema7BooleanType - | JsonSchema7DateType - | JsonSchema7EnumType - | JsonSchema7LiteralType - | JsonSchema7NativeEnumType - | JsonSchema7NullType - | JsonSchema7NumberType - | JsonSchema7ObjectType - | JsonSchema7RecordType - | JsonSchema7TupleType - | JsonSchema7UnionType - | JsonSchema7UndefinedType - | JsonSchema7RefType - | JsonSchema7NeverType - | JsonSchema7MapType - | JsonSchema7AnyType - | JsonSchema7NullableType - | JsonSchema7AllOfType - | JsonSchema7UnknownType - | JsonSchema7SetType; - -export type JsonSchema7Type = JsonSchema7TypeUnion & JsonSchema7Meta; - -export function parseDef( - def: ZodTypeDef, - refs: Refs, - forceResolution = false, // Forces a new schema to be instantiated even though its def has been seen. Used for improving refs in definitions. See https://github.com/StefanTerdell/zod-to-json-schema/pull/61. -): JsonSchema7Type | undefined { - const seenItem = refs.seen.get(def); - - if (refs.override) { - const overrideResult = refs.override?.(def, refs, seenItem, forceResolution); - - if (overrideResult !== ignoreOverride) { - return overrideResult; - } - } - - if (seenItem && !forceResolution) { - const seenSchema = get$ref(seenItem, refs); - - if (seenSchema !== undefined) { - if ('$ref' in seenSchema) { - refs.seenRefs.add(seenSchema.$ref); - } - - return seenSchema; - } - } - - const newItem: Seen = { def, path: refs.currentPath, jsonSchema: undefined }; - - refs.seen.set(def, newItem); - - const jsonSchema = selectParser(def, (def as any).typeName, refs, forceResolution); - - if (jsonSchema) { - addMeta(def, refs, jsonSchema); - } - - newItem.jsonSchema = jsonSchema; - - return jsonSchema; -} - -const get$ref = ( - item: Seen, - refs: Refs, -): - | { - $ref: string; - } - | {} - | undefined => { - switch (refs.$refStrategy) { - case 'root': - return { $ref: item.path.join('/') }; - // this case is needed as OpenAI strict mode doesn't support top-level `$ref`s, i.e. - // the top-level schema *must* be `{"type": "object", "properties": {...}}` but if we ever - // need to define a `$ref`, relative `$ref`s aren't supported, so we need to extract - // the schema to `#/definitions/` and reference that. - // - // e.g. if we need to reference a schema at - // `["#","definitions","contactPerson","properties","person1","properties","name"]` - // then we'll extract it out to `contactPerson_properties_person1_properties_name` - case 'extract-to-root': - const name = item.path.slice(refs.basePath.length + 1).join('_'); - - // we don't need to extract the root schema in this case, as it's already - // been added to the definitions - if (name !== refs.name && refs.nameStrategy === 'duplicate-ref') { - refs.definitions[name] = item.def; - } - - return { $ref: [...refs.basePath, refs.definitionPath, name].join('/') }; - case 'relative': - return { $ref: getRelativePath(refs.currentPath, item.path) }; - case 'none': - case 'seen': { - if ( - item.path.length < refs.currentPath.length && - item.path.every((value, index) => refs.currentPath[index] === value) - ) { - console.warn(`Recursive reference detected at ${refs.currentPath.join('/')}! Defaulting to any`); - - return {}; - } - - return refs.$refStrategy === 'seen' ? {} : undefined; - } - } -}; - -const getRelativePath = (pathA: string[], pathB: string[]) => { - let i = 0; - for (; i < pathA.length && i < pathB.length; i++) { - if (pathA[i] !== pathB[i]) break; - } - return [(pathA.length - i).toString(), ...pathB.slice(i)].join('/'); -}; - -const selectParser = ( - def: any, - typeName: ZodFirstPartyTypeKind, - refs: Refs, - forceResolution: boolean, -): JsonSchema7Type | undefined => { - switch (typeName) { - case ZodFirstPartyTypeKind.ZodString: - return parseStringDef(def, refs); - case ZodFirstPartyTypeKind.ZodNumber: - return parseNumberDef(def, refs); - case ZodFirstPartyTypeKind.ZodObject: - return parseObjectDef(def, refs); - case ZodFirstPartyTypeKind.ZodBigInt: - return parseBigintDef(def, refs); - case ZodFirstPartyTypeKind.ZodBoolean: - return parseBooleanDef(); - case ZodFirstPartyTypeKind.ZodDate: - return parseDateDef(def, refs); - case ZodFirstPartyTypeKind.ZodUndefined: - return parseUndefinedDef(); - case ZodFirstPartyTypeKind.ZodNull: - return parseNullDef(refs); - case ZodFirstPartyTypeKind.ZodArray: - return parseArrayDef(def, refs); - case ZodFirstPartyTypeKind.ZodUnion: - case ZodFirstPartyTypeKind.ZodDiscriminatedUnion: - return parseUnionDef(def, refs); - case ZodFirstPartyTypeKind.ZodIntersection: - return parseIntersectionDef(def, refs); - case ZodFirstPartyTypeKind.ZodTuple: - return parseTupleDef(def, refs); - case ZodFirstPartyTypeKind.ZodRecord: - return parseRecordDef(def, refs); - case ZodFirstPartyTypeKind.ZodLiteral: - return parseLiteralDef(def, refs); - case ZodFirstPartyTypeKind.ZodEnum: - return parseEnumDef(def); - case ZodFirstPartyTypeKind.ZodNativeEnum: - return parseNativeEnumDef(def); - case ZodFirstPartyTypeKind.ZodNullable: - return parseNullableDef(def, refs); - case ZodFirstPartyTypeKind.ZodOptional: - return parseOptionalDef(def, refs); - case ZodFirstPartyTypeKind.ZodMap: - return parseMapDef(def, refs); - case ZodFirstPartyTypeKind.ZodSet: - return parseSetDef(def, refs); - case ZodFirstPartyTypeKind.ZodLazy: - return parseDef(def.getter()._def, refs); - case ZodFirstPartyTypeKind.ZodPromise: - return parsePromiseDef(def, refs); - case ZodFirstPartyTypeKind.ZodNaN: - case ZodFirstPartyTypeKind.ZodNever: - return parseNeverDef(); - case ZodFirstPartyTypeKind.ZodEffects: - return parseEffectsDef(def, refs, forceResolution); - case ZodFirstPartyTypeKind.ZodAny: - return parseAnyDef(); - case ZodFirstPartyTypeKind.ZodUnknown: - return parseUnknownDef(); - case ZodFirstPartyTypeKind.ZodDefault: - return parseDefaultDef(def, refs); - case ZodFirstPartyTypeKind.ZodBranded: - return parseBrandedDef(def, refs); - case ZodFirstPartyTypeKind.ZodReadonly: - return parseReadonlyDef(def, refs); - case ZodFirstPartyTypeKind.ZodCatch: - return parseCatchDef(def, refs); - case ZodFirstPartyTypeKind.ZodPipeline: - return parsePipelineDef(def, refs); - case ZodFirstPartyTypeKind.ZodFunction: - case ZodFirstPartyTypeKind.ZodVoid: - case ZodFirstPartyTypeKind.ZodSymbol: - return undefined; - default: - return ((_: never) => undefined)(typeName); - } -}; - -const addMeta = (def: ZodTypeDef, refs: Refs, jsonSchema: JsonSchema7Type): JsonSchema7Type => { - if (def.description) { - jsonSchema.description = def.description; - - if (refs.markdownDescription) { - jsonSchema.markdownDescription = def.description; - } - } - return jsonSchema; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/any.ts b/src/_vendor/zod-to-json-schema/parsers/any.ts deleted file mode 100644 index 68c2921da..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/any.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type JsonSchema7AnyType = {}; - -export function parseAnyDef(): JsonSchema7AnyType { - return {}; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/array.ts b/src/_vendor/zod-to-json-schema/parsers/array.ts deleted file mode 100644 index 97791d77a..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/array.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ZodArrayDef, ZodFirstPartyTypeKind } from 'zod/v3'; -import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export type JsonSchema7ArrayType = { - type: 'array'; - items?: JsonSchema7Type | undefined; - minItems?: number; - maxItems?: number; - errorMessages?: ErrorMessages; -}; - -export function parseArrayDef(def: ZodArrayDef, refs: Refs) { - const res: JsonSchema7ArrayType = { - type: 'array', - }; - if (def.type?._def?.typeName !== ZodFirstPartyTypeKind.ZodAny) { - res.items = parseDef(def.type._def, { - ...refs, - currentPath: [...refs.currentPath, 'items'], - }); - } - - if (def.minLength) { - setResponseValueAndErrors(res, 'minItems', def.minLength.value, def.minLength.message, refs); - } - if (def.maxLength) { - setResponseValueAndErrors(res, 'maxItems', def.maxLength.value, def.maxLength.message, refs); - } - if (def.exactLength) { - setResponseValueAndErrors(res, 'minItems', def.exactLength.value, def.exactLength.message, refs); - setResponseValueAndErrors(res, 'maxItems', def.exactLength.value, def.exactLength.message, refs); - } - return res; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/bigint.ts b/src/_vendor/zod-to-json-schema/parsers/bigint.ts deleted file mode 100644 index 59385d82b..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/bigint.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ZodBigIntDef } from 'zod/v3'; -import { Refs } from '../Refs.js'; -import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; - -export type JsonSchema7BigintType = { - type: 'integer'; - format: 'int64'; - minimum?: BigInt; - exclusiveMinimum?: BigInt; - maximum?: BigInt; - exclusiveMaximum?: BigInt; - multipleOf?: BigInt; - errorMessage?: ErrorMessages; -}; - -export function parseBigintDef(def: ZodBigIntDef, refs: Refs): JsonSchema7BigintType { - const res: JsonSchema7BigintType = { - type: 'integer', - format: 'int64', - }; - - if (!def.checks) return res; - - for (const check of def.checks) { - switch (check.kind) { - case 'min': - if (refs.target === 'jsonSchema7') { - if (check.inclusive) { - setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); - } else { - setResponseValueAndErrors(res, 'exclusiveMinimum', check.value, check.message, refs); - } - } else { - if (!check.inclusive) { - res.exclusiveMinimum = true as any; - } - setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); - } - break; - case 'max': - if (refs.target === 'jsonSchema7') { - if (check.inclusive) { - setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); - } else { - setResponseValueAndErrors(res, 'exclusiveMaximum', check.value, check.message, refs); - } - } else { - if (!check.inclusive) { - res.exclusiveMaximum = true as any; - } - setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); - } - break; - case 'multipleOf': - setResponseValueAndErrors(res, 'multipleOf', check.value, check.message, refs); - break; - } - } - return res; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/boolean.ts b/src/_vendor/zod-to-json-schema/parsers/boolean.ts deleted file mode 100644 index 715e41acc..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/boolean.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type JsonSchema7BooleanType = { - type: 'boolean'; -}; - -export function parseBooleanDef(): JsonSchema7BooleanType { - return { - type: 'boolean', - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/branded.ts b/src/_vendor/zod-to-json-schema/parsers/branded.ts deleted file mode 100644 index a09075ac2..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/branded.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ZodBrandedDef } from 'zod/v3'; -import { parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export function parseBrandedDef(_def: ZodBrandedDef, refs: Refs) { - return parseDef(_def.type._def, refs); -} diff --git a/src/_vendor/zod-to-json-schema/parsers/catch.ts b/src/_vendor/zod-to-json-schema/parsers/catch.ts deleted file mode 100644 index 923df6c62..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/catch.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ZodCatchDef } from 'zod/v3'; -import { parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export const parseCatchDef = (def: ZodCatchDef, refs: Refs) => { - return parseDef(def.innerType._def, refs); -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/date.ts b/src/_vendor/zod-to-json-schema/parsers/date.ts deleted file mode 100644 index 2286b939d..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/date.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { ZodDateDef } from 'zod/v3'; -import { Refs } from '../Refs.js'; -import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; -import { JsonSchema7NumberType } from './number.js'; -import { DateStrategy } from '../Options.js'; - -export type JsonSchema7DateType = - | { - type: 'integer' | 'string'; - format: 'unix-time' | 'date-time' | 'date'; - minimum?: number; - maximum?: number; - errorMessage?: ErrorMessages; - } - | { - anyOf: JsonSchema7DateType[]; - }; - -export function parseDateDef( - def: ZodDateDef, - refs: Refs, - overrideDateStrategy?: DateStrategy, -): JsonSchema7DateType { - const strategy = overrideDateStrategy ?? refs.dateStrategy; - - if (Array.isArray(strategy)) { - return { - anyOf: strategy.map((item, i) => parseDateDef(def, refs, item)), - }; - } - - switch (strategy) { - case 'string': - case 'format:date-time': - return { - type: 'string', - format: 'date-time', - }; - case 'format:date': - return { - type: 'string', - format: 'date', - }; - case 'integer': - return integerDateParser(def, refs); - } -} - -const integerDateParser = (def: ZodDateDef, refs: Refs) => { - const res: JsonSchema7DateType = { - type: 'integer', - format: 'unix-time', - }; - - if (refs.target === 'openApi3') { - return res; - } - - for (const check of def.checks) { - switch (check.kind) { - case 'min': - setResponseValueAndErrors( - res, - 'minimum', - check.value, // This is in milliseconds - check.message, - refs, - ); - break; - case 'max': - setResponseValueAndErrors( - res, - 'maximum', - check.value, // This is in milliseconds - check.message, - refs, - ); - break; - } - } - - return res; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/default.ts b/src/_vendor/zod-to-json-schema/parsers/default.ts deleted file mode 100644 index cca964314..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/default.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ZodDefaultDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export function parseDefaultDef(_def: ZodDefaultDef, refs: Refs): JsonSchema7Type & { default: any } { - return { - ...parseDef(_def.innerType._def, refs), - default: _def.defaultValue(), - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/effects.ts b/src/_vendor/zod-to-json-schema/parsers/effects.ts deleted file mode 100644 index 2b834f432..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/effects.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ZodEffectsDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export function parseEffectsDef( - _def: ZodEffectsDef, - refs: Refs, - forceResolution: boolean, -): JsonSchema7Type | undefined { - return refs.effectStrategy === 'input' ? parseDef(_def.schema._def, refs, forceResolution) : {}; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/enum.ts b/src/_vendor/zod-to-json-schema/parsers/enum.ts deleted file mode 100644 index ed459f33f..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/enum.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ZodEnumDef } from 'zod/v3'; - -export type JsonSchema7EnumType = { - type: 'string'; - enum: string[]; -}; - -export function parseEnumDef(def: ZodEnumDef): JsonSchema7EnumType { - return { - type: 'string', - enum: [...def.values], - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/intersection.ts b/src/_vendor/zod-to-json-schema/parsers/intersection.ts deleted file mode 100644 index 1ab5ce1c4..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/intersection.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ZodIntersectionDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; -import { JsonSchema7StringType } from './string.js'; - -export type JsonSchema7AllOfType = { - allOf: JsonSchema7Type[]; - unevaluatedProperties?: boolean; -}; - -const isJsonSchema7AllOfType = ( - type: JsonSchema7Type | JsonSchema7StringType, -): type is JsonSchema7AllOfType => { - if ('type' in type && type.type === 'string') return false; - return 'allOf' in type; -}; - -export function parseIntersectionDef( - def: ZodIntersectionDef, - refs: Refs, -): JsonSchema7AllOfType | JsonSchema7Type | undefined { - const allOf = [ - parseDef(def.left._def, { - ...refs, - currentPath: [...refs.currentPath, 'allOf', '0'], - }), - parseDef(def.right._def, { - ...refs, - currentPath: [...refs.currentPath, 'allOf', '1'], - }), - ].filter((x): x is JsonSchema7Type => !!x); - - let unevaluatedProperties: Pick | undefined = - refs.target === 'jsonSchema2019-09' ? { unevaluatedProperties: false } : undefined; - - const mergedAllOf: JsonSchema7Type[] = []; - // If either of the schemas is an allOf, merge them into a single allOf - allOf.forEach((schema) => { - if (isJsonSchema7AllOfType(schema)) { - mergedAllOf.push(...schema.allOf); - if (schema.unevaluatedProperties === undefined) { - // If one of the schemas has no unevaluatedProperties set, - // the merged schema should also have no unevaluatedProperties set - unevaluatedProperties = undefined; - } - } else { - let nestedSchema: JsonSchema7Type = schema; - if ('additionalProperties' in schema && schema.additionalProperties === false) { - const { additionalProperties, ...rest } = schema; - nestedSchema = rest; - } else { - // As soon as one of the schemas has additionalProperties set not to false, we allow unevaluatedProperties - unevaluatedProperties = undefined; - } - mergedAllOf.push(nestedSchema); - } - }); - return mergedAllOf.length ? - { - allOf: mergedAllOf, - ...unevaluatedProperties, - } - : undefined; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/literal.ts b/src/_vendor/zod-to-json-schema/parsers/literal.ts deleted file mode 100644 index 5452fbcf9..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/literal.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ZodLiteralDef } from 'zod/v3'; -import { Refs } from '../Refs.js'; - -export type JsonSchema7LiteralType = - | { - type: 'string' | 'number' | 'integer' | 'boolean'; - const: string | number | boolean; - } - | { - type: 'object' | 'array'; - }; - -export function parseLiteralDef(def: ZodLiteralDef, refs: Refs): JsonSchema7LiteralType { - const parsedType = typeof def.value; - if ( - parsedType !== 'bigint' && - parsedType !== 'number' && - parsedType !== 'boolean' && - parsedType !== 'string' - ) { - return { - type: Array.isArray(def.value) ? 'array' : 'object', - }; - } - - if (refs.target === 'openApi3') { - return { - type: parsedType === 'bigint' ? 'integer' : parsedType, - enum: [def.value], - } as any; - } - - return { - type: parsedType === 'bigint' ? 'integer' : parsedType, - const: def.value, - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/map.ts b/src/_vendor/zod-to-json-schema/parsers/map.ts deleted file mode 100644 index 81c50d21a..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/map.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ZodMapDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; -import { JsonSchema7RecordType, parseRecordDef } from './record.js'; - -export type JsonSchema7MapType = { - type: 'array'; - maxItems: 125; - items: { - type: 'array'; - items: [JsonSchema7Type, JsonSchema7Type]; - minItems: 2; - maxItems: 2; - }; -}; - -export function parseMapDef(def: ZodMapDef, refs: Refs): JsonSchema7MapType | JsonSchema7RecordType { - if (refs.mapStrategy === 'record') { - return parseRecordDef(def, refs); - } - - const keys = - parseDef(def.keyType._def, { - ...refs, - currentPath: [...refs.currentPath, 'items', 'items', '0'], - }) || {}; - const values = - parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, 'items', 'items', '1'], - }) || {}; - return { - type: 'array', - maxItems: 125, - items: { - type: 'array', - items: [keys, values], - minItems: 2, - maxItems: 2, - }, - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts b/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts deleted file mode 100644 index e3539883b..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/nativeEnum.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ZodNativeEnumDef } from 'zod/v3'; - -export type JsonSchema7NativeEnumType = { - type: 'string' | 'number' | ['string', 'number']; - enum: (string | number)[]; -}; - -export function parseNativeEnumDef(def: ZodNativeEnumDef): JsonSchema7NativeEnumType { - const object = def.values; - const actualKeys = Object.keys(def.values).filter((key: string) => { - return typeof object[object[key]!] !== 'number'; - }); - - const actualValues = actualKeys.map((key: string) => object[key]!); - - const parsedTypes = Array.from(new Set(actualValues.map((values: string | number) => typeof values))); - - return { - type: - parsedTypes.length === 1 ? - parsedTypes[0] === 'string' ? - 'string' - : 'number' - : ['string', 'number'], - enum: actualValues, - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/never.ts b/src/_vendor/zod-to-json-schema/parsers/never.ts deleted file mode 100644 index a5c7383d7..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/never.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type JsonSchema7NeverType = { - not: {}; -}; - -export function parseNeverDef(): JsonSchema7NeverType { - return { - not: {}, - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/null.ts b/src/_vendor/zod-to-json-schema/parsers/null.ts deleted file mode 100644 index 4da424be9..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/null.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Refs } from '../Refs.js'; - -export type JsonSchema7NullType = { - type: 'null'; -}; - -export function parseNullDef(refs: Refs): JsonSchema7NullType { - return refs.target === 'openApi3' ? - ({ - enum: ['null'], - nullable: true, - } as any) - : { - type: 'null', - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/nullable.ts b/src/_vendor/zod-to-json-schema/parsers/nullable.ts deleted file mode 100644 index 2838031b2..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/nullable.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ZodNullableDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; -import { JsonSchema7NullType } from './null.js'; -import { primitiveMappings } from './union.js'; - -export type JsonSchema7NullableType = - | { - anyOf: [JsonSchema7Type, JsonSchema7NullType]; - } - | { - type: [string, 'null']; - }; - -export function parseNullableDef(def: ZodNullableDef, refs: Refs): JsonSchema7NullableType | undefined { - if ( - ['ZodString', 'ZodNumber', 'ZodBigInt', 'ZodBoolean', 'ZodNull'].includes(def.innerType._def.typeName) && - (!def.innerType._def.checks || !def.innerType._def.checks.length) - ) { - if (refs.target === 'openApi3' || refs.nullableStrategy === 'property') { - return { - type: primitiveMappings[def.innerType._def.typeName as keyof typeof primitiveMappings], - nullable: true, - } as any; - } - - return { - type: [primitiveMappings[def.innerType._def.typeName as keyof typeof primitiveMappings], 'null'], - }; - } - - if (refs.target === 'openApi3') { - const base = parseDef(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath], - }); - - if (base && '$ref' in base) return { allOf: [base], nullable: true } as any; - - return base && ({ ...base, nullable: true } as any); - } - - const base = parseDef(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, 'anyOf', '0'], - }); - - return base && { anyOf: [base, { type: 'null' }] }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/number.ts b/src/_vendor/zod-to-json-schema/parsers/number.ts deleted file mode 100644 index 0536c70a7..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/number.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ZodNumberDef } from 'zod/v3'; -import { addErrorMessage, ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; -import { Refs } from '../Refs.js'; - -export type JsonSchema7NumberType = { - type: 'number' | 'integer'; - minimum?: number; - exclusiveMinimum?: number; - maximum?: number; - exclusiveMaximum?: number; - multipleOf?: number; - errorMessage?: ErrorMessages; -}; - -export function parseNumberDef(def: ZodNumberDef, refs: Refs): JsonSchema7NumberType { - const res: JsonSchema7NumberType = { - type: 'number', - }; - - if (!def.checks) return res; - - for (const check of def.checks) { - switch (check.kind) { - case 'int': - res.type = 'integer'; - addErrorMessage(res, 'type', check.message, refs); - break; - case 'min': - if (refs.target === 'jsonSchema7') { - if (check.inclusive) { - setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); - } else { - setResponseValueAndErrors(res, 'exclusiveMinimum', check.value, check.message, refs); - } - } else { - if (!check.inclusive) { - res.exclusiveMinimum = true as any; - } - setResponseValueAndErrors(res, 'minimum', check.value, check.message, refs); - } - break; - case 'max': - if (refs.target === 'jsonSchema7') { - if (check.inclusive) { - setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); - } else { - setResponseValueAndErrors(res, 'exclusiveMaximum', check.value, check.message, refs); - } - } else { - if (!check.inclusive) { - res.exclusiveMaximum = true as any; - } - setResponseValueAndErrors(res, 'maximum', check.value, check.message, refs); - } - break; - case 'multipleOf': - setResponseValueAndErrors(res, 'multipleOf', check.value, check.message, refs); - break; - } - } - return res; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/object.ts b/src/_vendor/zod-to-json-schema/parsers/object.ts deleted file mode 100644 index 7de05a245..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/object.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ZodObjectDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -function decideAdditionalProperties(def: ZodObjectDef, refs: Refs) { - if (refs.removeAdditionalStrategy === 'strict') { - return def.catchall._def.typeName === 'ZodNever' ? - def.unknownKeys !== 'strict' - : parseDef(def.catchall._def, { - ...refs, - currentPath: [...refs.currentPath, 'additionalProperties'], - }) ?? true; - } else { - return def.catchall._def.typeName === 'ZodNever' ? - def.unknownKeys === 'passthrough' - : parseDef(def.catchall._def, { - ...refs, - currentPath: [...refs.currentPath, 'additionalProperties'], - }) ?? true; - } -} - -export type JsonSchema7ObjectType = { - type: 'object'; - properties: Record; - additionalProperties: boolean | JsonSchema7Type; - required?: string[]; -}; - -export function parseObjectDef(def: ZodObjectDef, refs: Refs) { - const result: JsonSchema7ObjectType = { - type: 'object', - ...Object.entries(def.shape()).reduce( - ( - acc: { - properties: Record; - required: string[]; - }, - [propName, propDef], - ) => { - if (propDef === undefined || propDef._def === undefined) return acc; - const propertyPath = [...refs.currentPath, 'properties', propName]; - const parsedDef = parseDef(propDef._def, { - ...refs, - currentPath: propertyPath, - propertyPath, - }); - if (parsedDef === undefined) return acc; - if ( - refs.openaiStrictMode && - propDef.isOptional() && - !propDef.isNullable() && - typeof propDef._def?.defaultValue === 'undefined' - ) { - throw new Error( - `Zod field at \`${propertyPath.join( - '/', - )}\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required`, - ); - } - return { - properties: { - ...acc.properties, - [propName]: parsedDef, - }, - required: - propDef.isOptional() && !refs.openaiStrictMode ? acc.required : [...acc.required, propName], - }; - }, - { properties: {}, required: [] }, - ), - additionalProperties: decideAdditionalProperties(def, refs), - }; - if (!result.required!.length) delete result.required; - return result; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/optional.ts b/src/_vendor/zod-to-json-schema/parsers/optional.ts deleted file mode 100644 index b68f606a0..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/optional.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ZodOptionalDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export const parseOptionalDef = (def: ZodOptionalDef, refs: Refs): JsonSchema7Type | undefined => { - if ( - refs.propertyPath && - refs.currentPath.slice(0, refs.propertyPath.length).toString() === refs.propertyPath.toString() - ) { - return parseDef(def.innerType._def, { ...refs, currentPath: refs.currentPath }); - } - - const innerSchema = parseDef(def.innerType._def, { - ...refs, - currentPath: [...refs.currentPath, 'anyOf', '1'], - }); - - return innerSchema ? - { - anyOf: [ - { - not: {}, - }, - innerSchema, - ], - } - : {}; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/pipeline.ts b/src/_vendor/zod-to-json-schema/parsers/pipeline.ts deleted file mode 100644 index 8f4b1f4bd..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/pipeline.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ZodPipelineDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; -import { JsonSchema7AllOfType } from './intersection.js'; - -export const parsePipelineDef = ( - def: ZodPipelineDef, - refs: Refs, -): JsonSchema7AllOfType | JsonSchema7Type | undefined => { - if (refs.pipeStrategy === 'input') { - return parseDef(def.in._def, refs); - } else if (refs.pipeStrategy === 'output') { - return parseDef(def.out._def, refs); - } - - const a = parseDef(def.in._def, { - ...refs, - currentPath: [...refs.currentPath, 'allOf', '0'], - }); - const b = parseDef(def.out._def, { - ...refs, - currentPath: [...refs.currentPath, 'allOf', a ? '1' : '0'], - }); - - return { - allOf: [a, b].filter((x): x is JsonSchema7Type => x !== undefined), - }; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/promise.ts b/src/_vendor/zod-to-json-schema/parsers/promise.ts deleted file mode 100644 index 26f3268f0..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/promise.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ZodPromiseDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export function parsePromiseDef(def: ZodPromiseDef, refs: Refs): JsonSchema7Type | undefined { - return parseDef(def.type._def, refs); -} diff --git a/src/_vendor/zod-to-json-schema/parsers/readonly.ts b/src/_vendor/zod-to-json-schema/parsers/readonly.ts deleted file mode 100644 index 8cdfb3b1b..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/readonly.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ZodReadonlyDef } from 'zod/v3'; -import { parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export const parseReadonlyDef = (def: ZodReadonlyDef, refs: Refs) => { - return parseDef(def.innerType._def, refs); -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/record.ts b/src/_vendor/zod-to-json-schema/parsers/record.ts deleted file mode 100644 index eefdeb7e0..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/record.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ZodFirstPartyTypeKind, ZodMapDef, ZodRecordDef, ZodTypeAny } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; -import { JsonSchema7EnumType } from './enum.js'; -import { JsonSchema7ObjectType } from './object.js'; -import { JsonSchema7StringType, parseStringDef } from './string.js'; - -type JsonSchema7RecordPropertyNamesType = - | Omit - | Omit; - -export type JsonSchema7RecordType = { - type: 'object'; - additionalProperties: JsonSchema7Type; - propertyNames?: JsonSchema7RecordPropertyNamesType; -}; - -export function parseRecordDef( - def: ZodRecordDef | ZodMapDef, - refs: Refs, -): JsonSchema7RecordType { - if (refs.target === 'openApi3' && def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { - return { - type: 'object', - required: def.keyType._def.values, - properties: def.keyType._def.values.reduce( - (acc: Record, key: string) => ({ - ...acc, - [key]: - parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, 'properties', key], - }) ?? {}, - }), - {}, - ), - additionalProperties: false, - } satisfies JsonSchema7ObjectType as any; - } - - const schema: JsonSchema7RecordType = { - type: 'object', - additionalProperties: - parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, 'additionalProperties'], - }) ?? {}, - }; - - if (refs.target === 'openApi3') { - return schema; - } - - if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodString && def.keyType._def.checks?.length) { - const keyType: JsonSchema7RecordPropertyNamesType = Object.entries( - parseStringDef(def.keyType._def, refs), - ).reduce((acc, [key, value]) => (key === 'type' ? acc : { ...acc, [key]: value }), {}); - - return { - ...schema, - propertyNames: keyType, - }; - } else if (def.keyType?._def.typeName === ZodFirstPartyTypeKind.ZodEnum) { - return { - ...schema, - propertyNames: { - enum: def.keyType._def.values, - }, - }; - } - - return schema; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/set.ts b/src/_vendor/zod-to-json-schema/parsers/set.ts deleted file mode 100644 index 4b31ba61f..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/set.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { ZodSetDef } from 'zod/v3'; -import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export type JsonSchema7SetType = { - type: 'array'; - uniqueItems: true; - items?: JsonSchema7Type | undefined; - minItems?: number; - maxItems?: number; - errorMessage?: ErrorMessages; -}; - -export function parseSetDef(def: ZodSetDef, refs: Refs): JsonSchema7SetType { - const items = parseDef(def.valueType._def, { - ...refs, - currentPath: [...refs.currentPath, 'items'], - }); - - const schema: JsonSchema7SetType = { - type: 'array', - uniqueItems: true, - items, - }; - - if (def.minSize) { - setResponseValueAndErrors(schema, 'minItems', def.minSize.value, def.minSize.message, refs); - } - - if (def.maxSize) { - setResponseValueAndErrors(schema, 'maxItems', def.maxSize.value, def.maxSize.message, refs); - } - - return schema; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/string.ts b/src/_vendor/zod-to-json-schema/parsers/string.ts deleted file mode 100644 index 123f7ce62..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/string.ts +++ /dev/null @@ -1,400 +0,0 @@ -// @ts-nocheck -import { ZodStringDef } from 'zod/v3'; -import { ErrorMessages, setResponseValueAndErrors } from '../errorMessages.js'; -import { Refs } from '../Refs.js'; - -let emojiRegex: RegExp | undefined; - -/** - * Generated from the regular expressions found here as of 2024-05-22: - * https://github.com/colinhacks/zod/blob/master/src/types.ts. - * - * Expressions with /i flag have been changed accordingly. - */ -export const zodPatterns = { - /** - * `c` was changed to `[cC]` to replicate /i flag - */ - cuid: /^[cC][^\s-]{8,}$/, - cuid2: /^[0-9a-z]+$/, - ulid: /^[0-9A-HJKMNP-TV-Z]{26}$/, - /** - * `a-z` was added to replicate /i flag - */ - email: /^(?!\.)(?!.*\.\.)([a-zA-Z0-9_'+\-\.]*)[a-zA-Z0-9_+-]@([a-zA-Z0-9][a-zA-Z0-9\-]*\.)+[a-zA-Z]{2,}$/, - /** - * Constructed a valid Unicode RegExp - * - * Lazily instantiate since this type of regex isn't supported - * in all envs (e.g. React Native). - * - * See: - * https://github.com/colinhacks/zod/issues/2433 - * Fix in Zod: - * https://github.com/colinhacks/zod/commit/9340fd51e48576a75adc919bff65dbc4a5d4c99b - */ - emoji: () => { - if (emojiRegex === undefined) { - emojiRegex = RegExp('^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$', 'u'); - } - return emojiRegex; - }, - /** - * Unused - */ - uuid: /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/, - /** - * Unused - */ - ipv4: /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, - /** - * Unused - */ - ipv6: /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/, - base64: /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/, - nanoid: /^[a-zA-Z0-9_-]{21}$/, -} as const; - -export type JsonSchema7StringType = { - type: 'string'; - minLength?: number; - maxLength?: number; - format?: - | 'email' - | 'idn-email' - | 'uri' - | 'uuid' - | 'date-time' - | 'ipv4' - | 'ipv6' - | 'date' - | 'time' - | 'duration'; - pattern?: string; - allOf?: { - pattern: string; - errorMessage?: ErrorMessages<{ pattern: string }>; - }[]; - anyOf?: { - format: string; - errorMessage?: ErrorMessages<{ format: string }>; - }[]; - errorMessage?: ErrorMessages; - contentEncoding?: string; -}; - -export function parseStringDef(def: ZodStringDef, refs: Refs): JsonSchema7StringType { - const res: JsonSchema7StringType = { - type: 'string', - }; - - function processPattern(value: string): string { - return refs.patternStrategy === 'escape' ? escapeNonAlphaNumeric(value) : value; - } - - if (def.checks) { - for (const check of def.checks) { - switch (check.kind) { - case 'min': - setResponseValueAndErrors( - res, - 'minLength', - typeof res.minLength === 'number' ? Math.max(res.minLength, check.value) : check.value, - check.message, - refs, - ); - break; - case 'max': - setResponseValueAndErrors( - res, - 'maxLength', - typeof res.maxLength === 'number' ? Math.min(res.maxLength, check.value) : check.value, - check.message, - refs, - ); - - break; - case 'email': - switch (refs.emailStrategy) { - case 'format:email': - addFormat(res, 'email', check.message, refs); - break; - case 'format:idn-email': - addFormat(res, 'idn-email', check.message, refs); - break; - case 'pattern:zod': - addPattern(res, zodPatterns.email, check.message, refs); - break; - } - - break; - case 'url': - addFormat(res, 'uri', check.message, refs); - break; - case 'uuid': - addFormat(res, 'uuid', check.message, refs); - break; - case 'regex': - addPattern(res, check.regex, check.message, refs); - break; - case 'cuid': - addPattern(res, zodPatterns.cuid, check.message, refs); - break; - case 'cuid2': - addPattern(res, zodPatterns.cuid2, check.message, refs); - break; - case 'startsWith': - addPattern(res, RegExp(`^${processPattern(check.value)}`), check.message, refs); - break; - case 'endsWith': - addPattern(res, RegExp(`${processPattern(check.value)}$`), check.message, refs); - break; - - case 'datetime': - addFormat(res, 'date-time', check.message, refs); - break; - case 'date': - addFormat(res, 'date', check.message, refs); - break; - case 'time': - addFormat(res, 'time', check.message, refs); - break; - case 'duration': - addFormat(res, 'duration', check.message, refs); - break; - case 'length': - setResponseValueAndErrors( - res, - 'minLength', - typeof res.minLength === 'number' ? Math.max(res.minLength, check.value) : check.value, - check.message, - refs, - ); - setResponseValueAndErrors( - res, - 'maxLength', - typeof res.maxLength === 'number' ? Math.min(res.maxLength, check.value) : check.value, - check.message, - refs, - ); - break; - case 'includes': { - addPattern(res, RegExp(processPattern(check.value)), check.message, refs); - break; - } - case 'ip': { - if (check.version !== 'v6') { - addFormat(res, 'ipv4', check.message, refs); - } - if (check.version !== 'v4') { - addFormat(res, 'ipv6', check.message, refs); - } - break; - } - case 'emoji': - addPattern(res, zodPatterns.emoji, check.message, refs); - break; - case 'ulid': { - addPattern(res, zodPatterns.ulid, check.message, refs); - break; - } - case 'base64': { - switch (refs.base64Strategy) { - case 'format:binary': { - addFormat(res, 'binary' as any, check.message, refs); - break; - } - - case 'contentEncoding:base64': { - setResponseValueAndErrors(res, 'contentEncoding', 'base64', check.message, refs); - break; - } - - case 'pattern:zod': { - addPattern(res, zodPatterns.base64, check.message, refs); - break; - } - } - break; - } - case 'nanoid': { - addPattern(res, zodPatterns.nanoid, check.message, refs); - } - case 'toLowerCase': - case 'toUpperCase': - case 'trim': - break; - default: - ((_: never) => {})(check); - } - } - } - - return res; -} - -const escapeNonAlphaNumeric = (value: string) => - Array.from(value) - .map((c) => (/[a-zA-Z0-9]/.test(c) ? c : `\\${c}`)) - .join(''); - -const addFormat = ( - schema: JsonSchema7StringType, - value: Required['format'], - message: string | undefined, - refs: Refs, -) => { - if (schema.format || schema.anyOf?.some((x) => x.format)) { - if (!schema.anyOf) { - schema.anyOf = []; - } - - if (schema.format) { - schema.anyOf!.push({ - format: schema.format, - ...(schema.errorMessage && - refs.errorMessages && { - errorMessage: { format: schema.errorMessage.format }, - }), - }); - delete schema.format; - if (schema.errorMessage) { - delete schema.errorMessage.format; - if (Object.keys(schema.errorMessage).length === 0) { - delete schema.errorMessage; - } - } - } - - schema.anyOf!.push({ - format: value, - ...(message && refs.errorMessages && { errorMessage: { format: message } }), - }); - } else { - setResponseValueAndErrors(schema, 'format', value, message, refs); - } -}; - -const addPattern = ( - schema: JsonSchema7StringType, - regex: RegExp | (() => RegExp), - message: string | undefined, - refs: Refs, -) => { - if (schema.pattern || schema.allOf?.some((x) => x.pattern)) { - if (!schema.allOf) { - schema.allOf = []; - } - - if (schema.pattern) { - schema.allOf!.push({ - pattern: schema.pattern, - ...(schema.errorMessage && - refs.errorMessages && { - errorMessage: { pattern: schema.errorMessage.pattern }, - }), - }); - delete schema.pattern; - if (schema.errorMessage) { - delete schema.errorMessage.pattern; - if (Object.keys(schema.errorMessage).length === 0) { - delete schema.errorMessage; - } - } - } - - schema.allOf!.push({ - pattern: processRegExp(regex, refs), - ...(message && refs.errorMessages && { errorMessage: { pattern: message } }), - }); - } else { - setResponseValueAndErrors(schema, 'pattern', processRegExp(regex, refs), message, refs); - } -}; - -// Mutate z.string.regex() in a best attempt to accommodate for regex flags when applyRegexFlags is true -const processRegExp = (regexOrFunction: RegExp | (() => RegExp), refs: Refs): string => { - const regex = typeof regexOrFunction === 'function' ? regexOrFunction() : regexOrFunction; - if (!refs.applyRegexFlags || !regex.flags) return regex.source; - - // Currently handled flags - const flags = { - i: regex.flags.includes('i'), // Case-insensitive - m: regex.flags.includes('m'), // `^` and `$` matches adjacent to newline characters - s: regex.flags.includes('s'), // `.` matches newlines - }; - - // The general principle here is to step through each character, one at a time, applying mutations as flags require. We keep track when the current character is escaped, and when it's inside a group /like [this]/ or (also) a range like /[a-z]/. The following is fairly brittle imperative code; edit at your peril! - - const source = flags.i ? regex.source.toLowerCase() : regex.source; - let pattern = ''; - let isEscaped = false; - let inCharGroup = false; - let inCharRange = false; - - for (let i = 0; i < source.length; i++) { - if (isEscaped) { - pattern += source[i]; - isEscaped = false; - continue; - } - - if (flags.i) { - if (inCharGroup) { - if (source[i].match(/[a-z]/)) { - if (inCharRange) { - pattern += source[i]; - pattern += `${source[i - 2]}-${source[i]}`.toUpperCase(); - inCharRange = false; - } else if (source[i + 1] === '-' && source[i + 2]?.match(/[a-z]/)) { - pattern += source[i]; - inCharRange = true; - } else { - pattern += `${source[i]}${source[i].toUpperCase()}`; - } - continue; - } - } else if (source[i].match(/[a-z]/)) { - pattern += `[${source[i]}${source[i].toUpperCase()}]`; - continue; - } - } - - if (flags.m) { - if (source[i] === '^') { - pattern += `(^|(?<=[\r\n]))`; - continue; - } else if (source[i] === '$') { - pattern += `($|(?=[\r\n]))`; - continue; - } - } - - if (flags.s && source[i] === '.') { - pattern += inCharGroup ? `${source[i]}\r\n` : `[${source[i]}\r\n]`; - continue; - } - - pattern += source[i]; - if (source[i] === '\\') { - isEscaped = true; - } else if (inCharGroup && source[i] === ']') { - inCharGroup = false; - } else if (!inCharGroup && source[i] === '[') { - inCharGroup = true; - } - } - - try { - const regexTest = new RegExp(pattern); - } catch { - console.warn( - `Could not convert regex pattern at ${refs.currentPath.join( - '/', - )} to a flag-independent form! Falling back to the flag-ignorant source`, - ); - return regex.source; - } - - return pattern; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/tuple.ts b/src/_vendor/zod-to-json-schema/parsers/tuple.ts deleted file mode 100644 index 79be7ded1..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/tuple.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ZodTupleDef, ZodTupleItems, ZodTypeAny } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export type JsonSchema7TupleType = { - type: 'array'; - minItems: number; - items: JsonSchema7Type[]; -} & ( - | { - maxItems: number; - } - | { - additionalItems?: JsonSchema7Type | undefined; - } -); - -export function parseTupleDef( - def: ZodTupleDef, - refs: Refs, -): JsonSchema7TupleType { - if (def.rest) { - return { - type: 'array', - minItems: def.items.length, - items: def.items - .map((x, i) => - parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, 'items', `${i}`], - }), - ) - .reduce((acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), []), - additionalItems: parseDef(def.rest._def, { - ...refs, - currentPath: [...refs.currentPath, 'additionalItems'], - }), - }; - } else { - return { - type: 'array', - minItems: def.items.length, - maxItems: def.items.length, - items: def.items - .map((x, i) => - parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, 'items', `${i}`], - }), - ) - .reduce((acc: JsonSchema7Type[], x) => (x === undefined ? acc : [...acc, x]), []), - }; - } -} diff --git a/src/_vendor/zod-to-json-schema/parsers/undefined.ts b/src/_vendor/zod-to-json-schema/parsers/undefined.ts deleted file mode 100644 index 6864d8138..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/undefined.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type JsonSchema7UndefinedType = { - not: {}; -}; - -export function parseUndefinedDef(): JsonSchema7UndefinedType { - return { - not: {}, - }; -} diff --git a/src/_vendor/zod-to-json-schema/parsers/union.ts b/src/_vendor/zod-to-json-schema/parsers/union.ts deleted file mode 100644 index 6018d8ce1..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/union.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { ZodDiscriminatedUnionDef, ZodLiteralDef, ZodTypeAny, ZodUnionDef } from 'zod/v3'; -import { JsonSchema7Type, parseDef } from '../parseDef.js'; -import { Refs } from '../Refs.js'; - -export const primitiveMappings = { - ZodString: 'string', - ZodNumber: 'number', - ZodBigInt: 'integer', - ZodBoolean: 'boolean', - ZodNull: 'null', -} as const; -type ZodPrimitive = keyof typeof primitiveMappings; -type JsonSchema7Primitive = (typeof primitiveMappings)[keyof typeof primitiveMappings]; - -export type JsonSchema7UnionType = JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType; - -type JsonSchema7PrimitiveUnionType = - | { - type: JsonSchema7Primitive | JsonSchema7Primitive[]; - } - | { - type: JsonSchema7Primitive | JsonSchema7Primitive[]; - enum: (string | number | bigint | boolean | null)[]; - }; - -type JsonSchema7AnyOfType = { - anyOf: JsonSchema7Type[]; -}; - -export function parseUnionDef( - def: ZodUnionDef | ZodDiscriminatedUnionDef, - refs: Refs, -): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined { - if (refs.target === 'openApi3') return asAnyOf(def, refs); - - const options: readonly ZodTypeAny[] = - def.options instanceof Map ? Array.from(def.options.values()) : def.options; - - // This blocks tries to look ahead a bit to produce nicer looking schemas with type array instead of anyOf. - if ( - options.every((x) => x._def.typeName in primitiveMappings && (!x._def.checks || !x._def.checks.length)) - ) { - // all types in union are primitive and lack checks, so might as well squash into {type: [...]} - - const types = options.reduce((types: JsonSchema7Primitive[], x) => { - const type = primitiveMappings[x._def.typeName as ZodPrimitive]; //Can be safely casted due to row 43 - return type && !types.includes(type) ? [...types, type] : types; - }, []); - - return { - type: types.length > 1 ? types : types[0]!, - }; - } else if (options.every((x) => x._def.typeName === 'ZodLiteral' && !x.description)) { - // all options literals - - const types = options.reduce((acc: JsonSchema7Primitive[], x: { _def: ZodLiteralDef }) => { - const type = typeof x._def.value; - switch (type) { - case 'string': - case 'number': - case 'boolean': - return [...acc, type]; - case 'bigint': - return [...acc, 'integer' as const]; - case 'object': - if (x._def.value === null) return [...acc, 'null' as const]; - case 'symbol': - case 'undefined': - case 'function': - default: - return acc; - } - }, []); - - if (types.length === options.length) { - // all the literals are primitive, as far as null can be considered primitive - - const uniqueTypes = types.filter((x, i, a) => a.indexOf(x) === i); - return { - type: uniqueTypes.length > 1 ? uniqueTypes : uniqueTypes[0]!, - enum: options.reduce( - (acc, x) => { - return acc.includes(x._def.value) ? acc : [...acc, x._def.value]; - }, - [] as (string | number | bigint | boolean | null)[], - ), - }; - } - } else if (options.every((x) => x._def.typeName === 'ZodEnum')) { - return { - type: 'string', - enum: options.reduce( - (acc: string[], x) => [...acc, ...x._def.values.filter((x: string) => !acc.includes(x))], - [], - ), - }; - } - - return asAnyOf(def, refs); -} - -const asAnyOf = ( - def: ZodUnionDef | ZodDiscriminatedUnionDef, - refs: Refs, -): JsonSchema7PrimitiveUnionType | JsonSchema7AnyOfType | undefined => { - const anyOf = ((def.options instanceof Map ? Array.from(def.options.values()) : def.options) as any[]) - .map((x, i) => - parseDef(x._def, { - ...refs, - currentPath: [...refs.currentPath, 'anyOf', `${i}`], - }), - ) - .filter( - (x): x is JsonSchema7Type => - !!x && (!refs.strictUnions || (typeof x === 'object' && Object.keys(x).length > 0)), - ); - - return anyOf.length ? { anyOf } : undefined; -}; diff --git a/src/_vendor/zod-to-json-schema/parsers/unknown.ts b/src/_vendor/zod-to-json-schema/parsers/unknown.ts deleted file mode 100644 index a3c8d1d96..000000000 --- a/src/_vendor/zod-to-json-schema/parsers/unknown.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type JsonSchema7UnknownType = {}; - -export function parseUnknownDef(): JsonSchema7UnknownType { - return {}; -} diff --git a/src/_vendor/zod-to-json-schema/util.ts b/src/_vendor/zod-to-json-schema/util.ts deleted file mode 100644 index 1c2f50105..000000000 --- a/src/_vendor/zod-to-json-schema/util.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ZodSchema, ZodTypeDef } from 'zod/v3'; - -export const zodDef = (zodSchema: ZodSchema | ZodTypeDef): ZodTypeDef => { - return '_def' in zodSchema ? zodSchema._def : zodSchema; -}; - -export function isEmptyObj(obj: Object | null | undefined): boolean { - if (!obj) return true; - for (const _k in obj) return false; - return true; -} diff --git a/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts b/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts deleted file mode 100644 index fe150d1b8..000000000 --- a/src/_vendor/zod-to-json-schema/zodToJsonSchema.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { ZodSchema } from 'zod/v3'; -import { Options, Targets } from './Options.js'; -import { JsonSchema7Type, parseDef } from './parseDef.js'; -import { getRefs } from './Refs.js'; -import { zodDef, isEmptyObj } from './util.js'; - -const zodToJsonSchema = ( - schema: ZodSchema, - options?: Partial> | string, -): (Target extends 'jsonSchema7' ? JsonSchema7Type : object) & { - $schema?: string; - definitions?: { - [key: string]: Target extends 'jsonSchema7' ? JsonSchema7Type - : Target extends 'jsonSchema2019-09' ? JsonSchema7Type - : object; - }; -} => { - const refs = getRefs(options); - - const name = - typeof options === 'string' ? options - : options?.nameStrategy === 'title' ? undefined - : options?.name; - - const main = - parseDef( - schema._def, - name === undefined ? refs : ( - { - ...refs, - currentPath: [...refs.basePath, refs.definitionPath, name], - } - ), - false, - ) ?? {}; - - const title = - typeof options === 'object' && options.name !== undefined && options.nameStrategy === 'title' ? - options.name - : undefined; - - if (title !== undefined) { - main.title = title; - } - - const definitions = (() => { - if (isEmptyObj(refs.definitions)) { - return undefined; - } - - const definitions: Record = {}; - const processedDefinitions = new Set(); - - // the call to `parseDef()` here might itself add more entries to `.definitions` - // so we need to continually evaluate definitions until we've resolved all of them - // - // we have a generous iteration limit here to avoid blowing up the stack if there - // are any bugs that would otherwise result in us iterating indefinitely - for (let i = 0; i < 500; i++) { - const newDefinitions = Object.entries(refs.definitions).filter( - ([key]) => !processedDefinitions.has(key), - ); - if (newDefinitions.length === 0) break; - - for (const [key, schema] of newDefinitions) { - definitions[key] = - parseDef( - zodDef(schema), - { ...refs, currentPath: [...refs.basePath, refs.definitionPath, key] }, - true, - ) ?? {}; - processedDefinitions.add(key); - } - } - - return definitions; - })(); - - const combined: ReturnType> = - name === undefined ? - definitions ? - { - ...main, - [refs.definitionPath]: definitions, - } - : main - : refs.nameStrategy === 'duplicate-ref' ? - { - ...main, - ...(definitions || refs.seenRefs.size ? - { - [refs.definitionPath]: { - ...definitions, - // only actually duplicate the schema definition if it was ever referenced - // otherwise the duplication is completely pointless - ...(refs.seenRefs.size ? { [name]: main } : undefined), - }, - } - : undefined), - } - : { - $ref: [...(refs.$refStrategy === 'relative' ? [] : refs.basePath), refs.definitionPath, name].join( - '/', - ), - [refs.definitionPath]: { - ...definitions, - [name]: main, - }, - }; - - if (refs.target === 'jsonSchema7') { - combined.$schema = 'http://json-schema.org/draft-07/schema#'; - } else if (refs.target === 'jsonSchema2019-09') { - combined.$schema = 'https://json-schema.org/draft/2019-09/schema#'; - } - - return combined; -}; - -export { zodToJsonSchema }; diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index 27b5a48dd..8b1d9b94a 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -10,7 +10,7 @@ import type * as z4c from 'zod/v4/core'; import * as z4mini from 'zod/v4-mini'; import { AnyObjectSchema, isZ4Schema } from './zod-compat.js'; -import { zodToJsonSchema } from '../_vendor/zod-to-json-schema/index.js'; +import { zodToJsonSchema } from 'zod-to-json-schema'; type JsonSchema = Record; From 2e02c259b96034577749f8077ad38f9033ae41c5 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Tue, 18 Nov 2025 10:02:37 -0800 Subject: [PATCH 10/13] chore: remove cast --- src/server/mcp.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 557759827..23798c138 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -97,18 +97,17 @@ describe('McpServer', () => { }, async ({ steps }, { sendNotification, _meta }) => { const progressToken = _meta?.progressToken; - const stepCount = steps as number; if (progressToken) { // Send progress notification for each step - for (let i = 1; i <= stepCount; i++) { + for (let i = 1; i <= steps; i++) { await sendNotification({ method: 'notifications/progress', params: { progressToken, progress: i, - total: stepCount, - message: `Completed step ${i} of ${stepCount}` + total: steps, + message: `Completed step ${i} of ${steps}` } }); } From 2cef25b330f7bbe2a230e1360ea9559aa3821481 Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Tue, 18 Nov 2025 10:20:57 -0800 Subject: [PATCH 11/13] chore: bring in new tests from recent merge --- src/client/v3/index.v3.test.ts | 14 +++--- src/server/index.ts | 6 ++- src/server/zod-json-schema-compat.ts | 51 +++++++++++++++++++++- src/shared/protocol.ts | 64 +--------------------------- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/src/client/v3/index.v3.test.ts b/src/client/v3/index.v3.test.ts index 67961b932..78a53eea0 100644 --- a/src/client/v3/index.v3.test.ts +++ b/src/client/v3/index.v3.test.ts @@ -3,7 +3,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { Client, getSupportedElicitationModes } from '../index.js'; import * as z from 'zod/v3'; -import { AnyObjectSchema } from '../../server/zod-compat.js'; import { RequestSchema, NotificationSchema, @@ -912,14 +911,16 @@ test('should apply defaults for form-mode elicitation when applyDefaults is enab * Test that custom request/notification/result schemas can be used with the Client class. */ test('should typecheck', () => { - const GetWeatherRequestSchema = RequestSchema.extend({ + const GetWeatherRequestSchema = z.object({ + ...RequestSchema.shape, method: z.literal('weather/get'), params: z.object({ city: z.string() }) }); - const GetForecastRequestSchema = RequestSchema.extend({ + const GetForecastRequestSchema = z.object({ + ...RequestSchema.shape, method: z.literal('weather/forecast'), params: z.object({ city: z.string(), @@ -927,7 +928,8 @@ test('should typecheck', () => { }) }); - const WeatherForecastNotificationSchema = NotificationSchema.extend({ + const WeatherForecastNotificationSchema = z.object({ + ...NotificationSchema.shape, method: z.literal('weather/alert'), params: z.object({ severity: z.enum(['warning', 'watch']), @@ -937,7 +939,9 @@ test('should typecheck', () => { const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema); const WeatherNotificationSchema = WeatherForecastNotificationSchema; - const WeatherResultSchema = ResultSchema.extend({ + const WeatherResultSchema = z.object({ + ...ResultSchema.shape, + _meta: z.record(z.string(), z.unknown()).optional(), temperature: z.number(), conditions: z.string() }); diff --git a/src/server/index.ts b/src/server/index.ts index 60751cc38..8de1a3cc4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -354,7 +354,7 @@ export class Server< params: LegacyElicitRequestFormParams | ElicitRequestFormParams | ElicitRequestURLParams, options?: RequestOptions ): Promise { - const mode = 'mode' in params ? params.mode : 'form'; + const mode = ('mode' in params ? params.mode : 'form') as 'form' | 'url'; switch (mode) { case 'url': { @@ -370,7 +370,9 @@ export class Server< throw new Error('Client does not support form elicitation.'); } const formParams: ElicitRequestFormParams = - 'mode' in params ? (params as ElicitRequestFormParams) : { ...(params as LegacyElicitRequestFormParams), mode: 'form' }; + 'mode' in params + ? (params as ElicitRequestFormParams) + : ({ ...(params as LegacyElicitRequestFormParams), mode: 'form' } as ElicitRequestFormParams); const result = await this.request({ method: 'elicitation/create', params: formParams }, ElicitResultSchema, options); diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index 8b1d9b94a..aab1891e7 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -9,7 +9,7 @@ import type * as z4c from 'zod/v4/core'; import * as z4mini from 'zod/v4-mini'; -import { AnyObjectSchema, isZ4Schema } from './zod-compat.js'; +import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, type ZodV3Internal, type ZodV4Internal } from './zod-compat.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; type JsonSchema = Record; @@ -43,3 +43,52 @@ export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): 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 getLiteralValue(schema: AnySchema): unknown { + if (isZ4Schema(schema)) { + const v4Schema = schema as unknown as ZodV4Internal; + const v4Def = v4Schema._zod?.def; + const candidates = [v4Def?.value, Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, v4Schema.value]; + + for (const candidate of candidates) { + if (typeof candidate !== 'undefined') { + return candidate; + } + } + } else { + const v3Schema = schema as unknown as ZodV3Internal; + const legacyDef = v3Schema._def; + const candidates = [legacyDef?.value, Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, v3Schema.value]; + + for (const candidate of candidates) { + if (typeof candidate !== 'undefined') { + return candidate; + } + } + } + + return undefined; +} + +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/protocol.ts b/src/shared/protocol.ts index e22df5db3..add69163c 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -1,13 +1,4 @@ -import { - AnySchema, - AnyObjectSchema, - SchemaOutput, - getObjectShape, - safeParse, - isZ4Schema, - type ZodV3Internal, - type ZodV4Internal -} from '../server/zod-compat.js'; +import { AnySchema, AnyObjectSchema, SchemaOutput, safeParse } from '../server/zod-compat.js'; import { CancelledNotificationSchema, ClientCapabilities, @@ -36,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. @@ -730,55 +722,3 @@ export function mergeCapabilities Date: Tue, 18 Nov 2025 10:30:42 -0800 Subject: [PATCH 12/13] chore: clean up extra function and types in compat layer --- src/server/zod-compat.ts | 2 -- src/server/zod-json-schema-compat.ts | 30 ++-------------------------- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/src/server/zod-compat.ts b/src/server/zod-compat.ts index 631c906f8..956aca821 100644 --- a/src/server/zod-compat.ts +++ b/src/server/zod-compat.ts @@ -46,8 +46,6 @@ export type SchemaOutput = S extends z3.ZodTypeAny ? z3.infer : S extends export type SchemaInput = S extends z3.ZodTypeAny ? z3.input : S extends z4.$ZodType ? z4.input : never; -export type ObjectOutput = SchemaOutput; - /** * 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. diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index aab1891e7..cde66b177 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -9,13 +9,13 @@ import type * as z4c from 'zod/v4/core'; import * as z4mini from 'zod/v4-mini'; -import { AnySchema, AnyObjectSchema, getObjectShape, safeParse, isZ4Schema, type ZodV3Internal, type ZodV4Internal } from './zod-compat.js'; +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 -export type CommonOpts = { +type CommonOpts = { strictUnions?: boolean; pipeStrategy?: 'input' | 'output'; target?: 'jsonSchema7' | 'draft-7' | 'jsonSchema2019-09' | 'draft-2020-12'; @@ -59,32 +59,6 @@ export function getMethodLiteral(schema: AnyObjectSchema): string { return value; } -export function getLiteralValue(schema: AnySchema): unknown { - if (isZ4Schema(schema)) { - const v4Schema = schema as unknown as ZodV4Internal; - const v4Def = v4Schema._zod?.def; - const candidates = [v4Def?.value, Array.isArray(v4Def?.values) ? v4Def.values[0] : undefined, v4Schema.value]; - - for (const candidate of candidates) { - if (typeof candidate !== 'undefined') { - return candidate; - } - } - } else { - const v3Schema = schema as unknown as ZodV3Internal; - const legacyDef = v3Schema._def; - const candidates = [legacyDef?.value, Array.isArray(legacyDef?.values) ? legacyDef.values[0] : undefined, v3Schema.value]; - - for (const candidate of candidates) { - if (typeof candidate !== 'undefined') { - return candidate; - } - } - } - - return undefined; -} - export function parseWithCompat(schema: AnySchema, data: unknown): unknown { const result = safeParse(schema, data); if (!result.success) { From 86b51fd0a21776a1a8e67f29f22f3cac573cd1be Mon Sep 17 00:00:00 2001 From: Devin Clark Date: Wed, 19 Nov 2025 09:14:14 -0800 Subject: [PATCH 13/13] chore: run prettier --- src/shared/auth.ts | 120 ++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 61 deletions(-) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 7e93a15c8..b37a4c70c 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -47,72 +47,70 @@ export const OAuthProtectedResourceMetadataSchema = z.looseObject({ /** * RFC 8414 OAuth 2.0 Authorization Server Metadata */ -export const OAuthMetadataSchema = z - .looseObject({ - issuer: z.string(), - authorization_endpoint: SafeUrlSchema, - token_endpoint: SafeUrlSchema, - registration_endpoint: SafeUrlSchema.optional(), - scopes_supported: z.array(z.string()).optional(), - response_types_supported: z.array(z.string()), - response_modes_supported: z.array(z.string()).optional(), - grant_types_supported: z.array(z.string()).optional(), - token_endpoint_auth_methods_supported: z.array(z.string()).optional(), - token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - service_documentation: SafeUrlSchema.optional(), - revocation_endpoint: SafeUrlSchema.optional(), - revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), - revocation_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - introspection_endpoint: z.string().optional(), - introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), - introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() - }); +export const OAuthMetadataSchema = z.looseObject({ + issuer: z.string(), + authorization_endpoint: SafeUrlSchema, + token_endpoint: SafeUrlSchema, + registration_endpoint: SafeUrlSchema.optional(), + scopes_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()), + response_modes_supported: z.array(z.string()).optional(), + grant_types_supported: z.array(z.string()).optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + service_documentation: SafeUrlSchema.optional(), + revocation_endpoint: SafeUrlSchema.optional(), + revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), + revocation_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + introspection_endpoint: z.string().optional(), + introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), + introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), + code_challenge_methods_supported: z.array(z.string()).optional(), + client_id_metadata_document_supported: z.boolean().optional() +}); /** * OpenID Connect Discovery 1.0 Provider Metadata * see: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata */ -export const OpenIdProviderMetadataSchema = z - .looseObject({ - issuer: z.string(), - authorization_endpoint: SafeUrlSchema, - token_endpoint: SafeUrlSchema, - userinfo_endpoint: SafeUrlSchema.optional(), - jwks_uri: SafeUrlSchema, - registration_endpoint: SafeUrlSchema.optional(), - scopes_supported: z.array(z.string()).optional(), - response_types_supported: z.array(z.string()), - response_modes_supported: z.array(z.string()).optional(), - grant_types_supported: z.array(z.string()).optional(), - acr_values_supported: z.array(z.string()).optional(), - subject_types_supported: z.array(z.string()), - id_token_signing_alg_values_supported: z.array(z.string()), - id_token_encryption_alg_values_supported: z.array(z.string()).optional(), - id_token_encryption_enc_values_supported: z.array(z.string()).optional(), - userinfo_signing_alg_values_supported: z.array(z.string()).optional(), - userinfo_encryption_alg_values_supported: z.array(z.string()).optional(), - userinfo_encryption_enc_values_supported: z.array(z.string()).optional(), - request_object_signing_alg_values_supported: z.array(z.string()).optional(), - request_object_encryption_alg_values_supported: z.array(z.string()).optional(), - request_object_encryption_enc_values_supported: z.array(z.string()).optional(), - token_endpoint_auth_methods_supported: z.array(z.string()).optional(), - token_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), - display_values_supported: z.array(z.string()).optional(), - claim_types_supported: z.array(z.string()).optional(), - claims_supported: z.array(z.string()).optional(), - service_documentation: z.string().optional(), - claims_locales_supported: z.array(z.string()).optional(), - ui_locales_supported: z.array(z.string()).optional(), - claims_parameter_supported: z.boolean().optional(), - request_parameter_supported: z.boolean().optional(), - request_uri_parameter_supported: z.boolean().optional(), - require_request_uri_registration: z.boolean().optional(), - op_policy_uri: SafeUrlSchema.optional(), - op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() - }); +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