Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6db0e68
chore: initial upgrade, broken tests
dclark27 Aug 12, 2025
08513d3
chore: replace zod-to-json-schema temporarily
dclark27 Aug 12, 2025
3b28aac
chore: remove dependency that is unused
dclark27 Aug 12, 2025
1cc8c7b
chore: fix formatting of mcp.test
dclark27 Aug 12, 2025
9b178b7
chore: add commas
dclark27 Aug 12, 2025
9298723
Merge branch 'main' into zod-v4
dclark27 Aug 12, 2025
aca892b
chore: skip custom json schema generation
dclark27 Aug 13, 2025
e2d351c
Merge branch 'main' into zod-v4
dclark27 Aug 14, 2025
2010203
Merge remote-tracking branch 'upstream/main' into zod-v4
dclark27 Aug 19, 2025
8531d8e
chore: use default instead of "prefault"
dclark27 Aug 19, 2025
1461fa2
Merge branch 'main' into zod-v4
dclark27 Aug 25, 2025
9cf8b2e
chore: remove comment
dclark27 Aug 25, 2025
5a761b5
Merge branch 'main' into zod-v4
dclark27 Aug 29, 2025
30fb023
Merge branch 'main' into zod-v4
dclark27 Sep 2, 2025
3cd0546
Merge remote-tracking branch 'upstream/main' into zod-v4
dclark27 Sep 9, 2025
5a64ed1
Merge branch 'main' into zod-v4
dclark27 Sep 17, 2025
ab26df8
chore: update to zod latest
dclark27 Sep 17, 2025
aa5e35b
Merge branch 'main' into zod-v4
dclark27 Oct 20, 2025
374a673
chore: use import from zod instead of zod/v4
dclark27 Oct 20, 2025
79f6c04
chore: imports
dclark27 Oct 20, 2025
a55f3aa
chore: use prefault over default
dclark27 Oct 20, 2025
467c0c2
chore: more prefault over default
dclark27 Oct 20, 2025
3440675
chore: update with main for auth
dclark27 Oct 20, 2025
e8e91ed
chore: ignore compiled dist mocks to fix duplicate/syntax errors
dclark27 Oct 20, 2025
b6b7d96
chore: remove zod-to-json-schema
dclark27 Oct 20, 2025
5813fc3
chore: import * as z from zod
dclark27 Oct 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export default {
"/node_modules/(?!eventsource)/"
],
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
modulePathIgnorePatterns: ["<rootDir>/dist"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? This seems like an unintentional change.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this in as I was seeing issues running the auth test suite. Adding this prevent the mocks from duplicating and was able to get my tests back running again. We can remove if perhaps it's just my local setup.

};
18 changes: 4 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,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": "^4.0.17"
},
"devDependencies": {
"@eslint/js": "^9.8.0",
Expand Down
2 changes: 1 addition & 1 deletion src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { z } from "zod/v4";
Copy link
Contributor

@felixweinberger felixweinberger Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found conflicting guidance on this import. https://zod.dev/library-authors?id=how-to-support-zod-4 suggests this import should never be used - it should be zod/v4/core.

On the other hand I'm not sure this repo counts as a "Library" as intended by these docs; currently zod isn't a peer dependency, so I'm not sure we need to support both v3 and v4 simultaneously in the code - we just need to ensure that clients using v3 are still compatible with servers using v4 and vice versa.

Ultimately because we have a server-client model in MCP and the two sides communicate via JSONRPC generated & parsed by Zod, I believe it doesn't actually matter if the sides use different versions of Zod. Just like a TypeScript based client might use Zod but a Python server would use Pydantic for validation. If true, my preference would be to just stick with import { z } from "zod"; and leave it at that without dealing with multiple versions unnecessarily.

Maybe that's something @colinhacks could help answer or suggest how we gain confidence on this? In general JSON generated by v3 should normally be parseable by v4 and vice versa I assume, I'm not sure what if anything would break over the wire.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, I've run some tests on this as I was trying to migrate a big project that uses modelcontextprotocol/typescript-sdk to Zod 4 in a big monorepo and I've had some issues with npm hosting Zod.
It seems that the issue with libraries doing import { z } from "zod" in this setup is that zod will use either Zod 3 if package.json of the host app contains "zod": "^3.25.76" and Zod 4 if the host is using "zod": "^4.0.0".

Not sure of the impact of peerDependencies vs devDependencies in the context of this project, but not specifying the import (zod/v3, zod/v4 or zod/v4/core) seems to be able to have some impacts in some contexts.

Copy link
Contributor

@felixweinberger felixweinberger Oct 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @mmorainville-fountain that's great input - in that case I don't see a downside to using the zod/v4 import explicitly.

After writing this review + speaking with @ochafik offline I also realized there's a bit more to worry about than just JSONRPC transport layer between clients and server.

The quick start shows how SDK users would define their input and output schema in terms of zod and they might be using zod v3 to pass into say registerTools. If we internally then use zod v4 within the SDK, I'm not sure that's directly compatible.

I'll see if I can figure out a way to do some testing to find out - given how highly requested this is I wouldn't want this to depend on v2 ideally, so we'd need something fully backwards compatible. Maybe moving to a peerDependency is the right move.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just spotted this comment from @dclark27 #869 (comment) exploring this with a potential regression test, will take a look at this.

import {
RequestSchema,
NotificationSchema,
Expand Down
2 changes: 1 addition & 1 deletion src/examples/server/jsonResponseStreamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { z } from 'zod/v4';
import { CallToolResult, isInitializeRequest } from '../../types.js';
import cors from 'cors';

Expand Down
2 changes: 1 addition & 1 deletion src/examples/server/mcpServerOutputSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { McpServer } from "../../server/mcp.js";
import { StdioServerTransport } from "../../server/stdio.js";
import { z } from "zod";
import { z } from "zod/v4";

const server = new McpServer(
{
Expand Down
6 changes: 3 additions & 3 deletions src/examples/server/simpleSseServer.ts
Original file line number Diff line number Diff line change
@@ -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 { z } from 'zod/v4';
import { CallToolResult } from '../../types.js';

/**
Expand All @@ -25,8 +25,8 @@ const getServer = () => {
'start-notification-stream',
'Starts sending periodic notifications',
{
interval: z.number().describe('Interval in milliseconds between notifications').default(1000),
count: z.number().describe('Number of notifications to send').default(10),
interval: z.number().describe('Interval in milliseconds between notifications').prefault(1000),
count: z.number().describe('Number of notifications to send').prefault(10),
},
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Expand Down
6 changes: 3 additions & 3 deletions src/examples/server/simpleStatelessStreamableHttp.ts
Original file line number Diff line number Diff line change
@@ -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 { z } from 'zod/v4';
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
import cors from 'cors';

Expand Down Expand Up @@ -39,8 +39,8 @@ const getServer = () => {
'start-notification-stream',
'Starts sending periodic notifications for testing resumability',
{
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
count: z.number().describe('Number of notifications to send (0 for 100)').default(10),
interval: z.number().describe('Interval in milliseconds between notifications').prefault(100),
count: z.number().describe('Number of notifications to send (0 for 100)').prefault(10),
},
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Expand Down
6 changes: 3 additions & 3 deletions src/examples/server/simpleStreamableHttp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import express, { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { z } from 'zod/v4';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
Expand Down Expand Up @@ -270,8 +270,8 @@ const getServer = () => {
'start-notification-stream',
'Starts sending periodic notifications for testing resumability',
{
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
interval: z.number().describe('Interval in milliseconds between notifications').prefault(100),
count: z.number().describe('Number of notifications to send (0 for 100)').prefault(50),
},
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Expand Down
6 changes: 3 additions & 3 deletions src/examples/server/sseAndStreamableHttpCompatibleServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { z } from 'zod/v4';
import { CallToolResult, isInitializeRequest } from '../../types.js';
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
import cors from 'cors';
Expand All @@ -30,8 +30,8 @@ const getServer = () => {
'start-notification-stream',
'Starts sending periodic notifications for testing resumability',
{
interval: z.number().describe('Interval in milliseconds between notifications').default(100),
count: z.number().describe('Number of notifications to send (0 for 100)').default(50),
interval: z.number().describe('Interval in milliseconds between notifications').prefault(100),
count: z.number().describe('Number of notifications to send (0 for 100)').prefault(50),
},
async ({ interval, count }, { sendNotification }): Promise<CallToolResult> => {
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
Expand Down
2 changes: 1 addition & 1 deletion src/examples/server/toolWithSampleServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { McpServer } from "../../server/mcp.js";
import { StdioServerTransport } from "../../server/stdio.js";
import { z } from "zod";
import { z } from "zod/v4";

const mcpServer = new McpServer({
name: "tools-with-sample-server",
Expand Down
4 changes: 2 additions & 2 deletions src/integration-tests/stateManagementStreamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 { z } from 'zod';
import { z } from 'zod/v4';

describe('Streamable HTTP Transport Session Management', () => {
// Function to set up the server with optional session management
Expand Down Expand Up @@ -55,7 +55,7 @@ describe('Streamable HTTP Transport Session Management', () => {
'greet',
'A simple greeting tool',
{
name: z.string().describe('Name to greet').default('World'),
name: z.string().describe('Name to greet').prefault('World'),
},
async ({ name }) => {
return {
Expand Down
8 changes: 4 additions & 4 deletions src/integration-tests/taskResumability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { z } from 'zod/v4';
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';


Expand All @@ -33,7 +33,7 @@ describe('Transport resumability', () => {
'send-notification',
'Sends a single notification',
{
message: z.string().describe('Message to send').default('Test notification')
message: z.string().describe('Message to send').prefault('Test notification')
},
async ({ message }, { sendNotification }) => {
// Send notification immediately
Expand All @@ -56,8 +56,8 @@ describe('Transport resumability', () => {
'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)
count: z.number().describe('Number of notifications to send').prefault(10),
interval: z.number().describe('Interval between notifications in ms').prefault(50)
},
async ({ count, interval }, { sendNotification }) => {
// Send notifications at specified intervals
Expand Down
8 changes: 5 additions & 3 deletions src/server/auth/handlers/authorize.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { z } from "zod/v4";
import express from "express";
import { OAuthServerProvider } from "../provider.js";
import { rateLimit, Options as RateLimitOptions } from "express-rate-limit";
Expand All @@ -25,7 +25,9 @@ export type AuthorizationHandlerOptions = {
// Parameters that must be validated in order to issue redirects.
const ClientAuthorizationParamsSchema = z.object({
client_id: z.string(),
redirect_uri: z.string().optional().refine((value) => value === undefined || URL.canParse(value), { message: "redirect_uri must be a valid URL" }),
redirect_uri: z.string().optional().refine((value) => value === undefined || URL.canParse(value), {
error: "redirect_uri must be a valid URL"
}),
});

// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
Expand All @@ -35,7 +37,7 @@ const RequestAuthorizationParamsSchema = z.object({
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
resource: z.string().url().optional(),
resource: z.url().optional(),
});

export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
Expand Down
6 changes: 3 additions & 3 deletions src/server/auth/handlers/token.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import express, { RequestHandler } from "express";
import { OAuthServerProvider } from "../provider.js";
import cors from "cors";
Expand Down Expand Up @@ -32,13 +32,13 @@ const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
redirect_uri: z.string().optional(),
resource: z.string().url().optional(),
resource: z.url().optional(),
});

const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
resource: z.string().url().optional(),
resource: z.url().optional(),
});

export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
Expand Down
2 changes: 1 addition & 1 deletion src/server/auth/middleware/clientAuth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { RequestHandler } from "express";
import { OAuthRegisteredClientsStore } from "../clients.js";
import { OAuthClientInformationFull } from "../../../shared/auth.js";
Expand Down
2 changes: 1 addition & 1 deletion src/server/completable.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
import { completable } from "./completable.js";

describe("completable", () => {
Expand Down
95 changes: 21 additions & 74 deletions src/server/completable.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,4 @@
import {
ZodTypeAny,
ZodTypeDef,
ZodType,
ParseInput,
ParseReturnType,
RawCreateParams,
ZodErrorMap,
ProcessedCreateParams,
} from "zod";
import { ZodTypeAny } from "zod/v4";

export enum McpZodTypeKind {
Completable = "McpCompletable",
Expand All @@ -17,82 +8,38 @@ export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
value: T["_input"],
context?: {
arguments?: Record<string, string>;
},
}
) => T["_input"][] | Promise<T["_input"][]>;

export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>
extends ZodTypeDef {
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny> {
type: T;
complete: CompleteCallback<T>;
typeName: McpZodTypeKind.Completable;
}

export class Completable<T extends ZodTypeAny> extends ZodType<
T["_output"],
CompletableDef<T>,
T["_input"]
> {
_parse(input: ParseInput): ParseReturnType<this["_output"]> {
const { ctx } = this._processInputParams(input);
const data = ctx.data;
return this._def.type._parse({
data,
path: ctx.path,
parent: ctx,
});
}

unwrap() {
return this._def.type;
}

static create = <T extends ZodTypeAny>(
type: T,
params: RawCreateParams & {
complete: CompleteCallback<T>;
},
): Completable<T> => {
return new Completable({
type,
typeName: McpZodTypeKind.Completable,
complete: params.complete,
...processCreateParams(params),
});
};
}

/**
* Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP.
*/
export function completable<T extends ZodTypeAny>(
schema: T,
complete: CompleteCallback<T>,
): Completable<T> {
return Completable.create(schema, { ...schema._def, complete });
}

// 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.`,
);
complete: CompleteCallback<T>
): T & {
_def: (T extends { _def: infer D } ? D : unknown) & CompletableDef<T>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first of all I want to claim that I am not a part of the mcp organization so I hope I am not a blocking phase for this feature. I really am anticipating for the SDK to support zod v4 and I want to thank you for your time and effort!

I took a little more time to further learn about zod and the SDK so I can provide some meaningful insights if possible

according to https://zod.dev/library-authors?id=how-to-support-zod-3-and-zod-4-simultaneously you can support both z3 and z4 which can make this feature (in case that the maintainers of this repo are willing) a non-breaking change so it can be part of sdk v1 rather than v2. I suggest asking the maintainers before re-implementing the whole PR of course!

besides backward compatibility, it looks like _def is a zod v3 style whereas in zod v4 it should be ._zod.def. I really am not sure about that, I assume you already stumbled upon this and I just am missing something but I'd love to hear what you have to say about this.

one thing that I am not sure about is your choice to as unknown as and the usage as in general. I suggest adding a comment that explains why this kind of type manipulation is required rather than using type guards (using simple conditions/ custom type predicates with is or maybe using zod) that will explain intentions better

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also am not sure about the completable feature and have never used it before

} {
const target = schema as unknown as { _def?: Record<string, unknown> };
const originalDef = (target._def ?? {}) as Record<string, unknown>;
// Only mutate the existing _def object to respect read-only property semantics
if (
(originalDef as { typeName?: unknown }).typeName !==
McpZodTypeKind.Completable
) {
(originalDef as { typeName?: McpZodTypeKind; type?: ZodTypeAny }).typeName =
McpZodTypeKind.Completable;
(originalDef as { typeName?: McpZodTypeKind; type?: ZodTypeAny }).type =
schema;
}
if (errorMap) return { errorMap: errorMap, description };
const customMap: ZodErrorMap = (iss, ctx) => {
const { message } = params;

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 };
(originalDef as { complete?: CompleteCallback<T> }).complete = complete;
return schema as unknown as T & {
_def: (T extends { _def: infer D } ? D : unknown) & CompletableDef<T>;
};
return { errorMap: customMap, description };
}
Loading