Skip to content

v2 backwards compat: specTypeSchema exported - synchronous StandardSchemaV1#2047

Open
KKonstantinov wants to merge 1 commit into
mainfrom
feature/fix-sync-validate
Open

v2 backwards compat: specTypeSchema exported - synchronous StandardSchemaV1#2047
KKonstantinov wants to merge 1 commit into
mainfrom
feature/fix-sync-validate

Conversation

@KKonstantinov
Copy link
Copy Markdown
Contributor

Add StandardSchemaV1Sync type so specTypeSchemas entries expose synchronous validate

Motivation and Context

specTypeSchemas exposes every MCP spec schema as a StandardSchemaV1. The StandardSchemaV1 interface defines validate as returning Result<Output> | Promise<Result<Output>>, because the Standard Schema spec supports async validation libraries. However, every entry in specTypeSchemas is backed by Zod, which always validates synchronously -- the Promise variant is never returned at runtime.

This causes a real problem for consumers: calling specTypeSchemas.X['~standard'].validate(v) and then accessing .issues or .value on the result produces a type error, because TypeScript thinks the result might be a Promise.

const result = specTypeSchemas.CreateMessageResult['~standard'].validate(data);
// TS error: Property 'issues' does not exist on type 'Result<...> | Promise<Result<...>>'
if (result.issues !== undefined) { ... }

How Has This Been Tested?

  • pnpm typecheck:all -- all packages typecheck cleanly
  • pnpm test:all -- all 1532 tests pass across all packages

Breaking Changes

No. StandardSchemaV1Sync extends StandardSchemaV1, so specTypeSchemas entries remain assignable anywhere a StandardSchemaV1 is expected. The change only narrows the return type of validate from Result | Promise<Result> to Result.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Three files changed (types only, no runtime changes):

  • packages/core/src/util/standardSchema.ts -- defines StandardSchemaV1Sync, which extends StandardSchemaV1 but narrows validate to return StandardSchemaV1.Result<Output> (no Promise variant)
  • packages/core/src/types/specTypeSchema.ts -- SchemaRecord now uses StandardSchemaV1Sync instead of StandardSchemaV1
  • packages/core/src/exports/public/index.ts -- exports StandardSchemaV1Sync so consumers can reference the type

APIs that accept user-provided schemas (e.g. McpServer.tool()) still use StandardSchemaV1 / StandardSchemaWithJSON, so async validation libraries remain fully supported.

@KKonstantinov KKonstantinov requested a review from a team as a code owner May 11, 2026 15:22
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

⚠️ No Changeset found

Latest commit: 34bccc9

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2047

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2047

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2047

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2047

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2047

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2047

commit: 34bccc9

};

type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1<SpecTypeInputs[K], SpecTypes[K]> };
type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync<SpecTypeInputs[K], SpecTypes[K]> };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 nit: Now that SchemaRecord uses StandardSchemaV1Sync, the docs/examples that demonstrate specTypeSchemas still show the old async pattern this PR exists to remove — the JSDoc @example here (sourced from specTypeSchema.examples.ts), docs/migration.md:510-513, and docs/migration-SKILL.md:102/471-472 all still await the validate call and describe the entry as StandardSchemaV1<In, Out>. The await is harmless at runtime, but it'd be worth dropping async/await, referencing StandardSchemaV1Sync in the prose, and re-running pnpm sync:snippets so the canonical examples advertise the synchronous usage this PR enables.

Extended reasoning...

What the issue is

This PR introduces StandardSchemaV1Sync and retypes SchemaRecord (the type of specTypeSchemas) to use it, so that validate() returns Result<Output> directly rather than Result<Output> | Promise<Result<Output>>. The new JSDoc on StandardSchemaV1Sync (standardSchema.ts:117-125) explicitly states: "Consumers can call validate() and access .issues / .value on the result without await."

However, several pieces of documentation that describe specTypeSchemas still demonstrate the old async pattern:

  • packages/core/src/types/specTypeSchema.ts:268 — the JSDoc @example block on specTypeSchemas shows const result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);. This fence is synced from packages/core/src/types/specTypeSchema.examples.ts:16-24 (the specTypeSchemas_basicUsage region), which is wrapped in an async function and awaits the call.
  • docs/migration.md:510const result = await specTypeSchemas.CallToolResult['~standard'].validate(value);
  • docs/migration.md:513 — "specTypeSchemas.X is a StandardSchemaV1<In, Out>"
  • docs/migration-SKILL.md:471 — table row maps <TypeName>Schema.parse(value)await specTypeSchemas.<TypeName>['~standard'].validate(value)
  • docs/migration-SKILL.md:472 — "specTypeSchemas.<TypeName> (a StandardSchemaV1<In, Out>)"
  • docs/migration-SKILL.md:102 — "for the StandardSchemaV1 validator object"

Why this matters

The PR's stated motivation is precisely that users currently can't write if (result.issues !== undefined) without an await, because TypeScript thinks the result might be a Promise. After this PR they can — but the only @example on the export, the v1→v2 migration guide, and the migration SKILL (which drives automated migrations) all still tell users to await and describe the entry type as the async-capable StandardSchemaV1. The canonical documentation therefore demonstrates exactly the pattern the PR exists to eliminate, and fails to advertise the improvement to the users who need it most.

Why existing code doesn't prevent it

None of this is incorrect. await on a non-thenable simply yields the value, so the examples still compile and run. And StandardSchemaV1Sync extends StandardSchemaV1, so "is a StandardSchemaV1<In, Out>" remains technically true. That's why typecheck and tests pass — there is no mechanism (nor should there be) that fails the build when prose describes a superseded-but-still-valid pattern. This is purely a doc-freshness issue, falling under REVIEW.md's "Bugfix or behavior change: check whether docs describe the old behavior and needs updating; flag prose that now contradicts the implementation."

Step-by-step proof

  1. After this PR, specTypeSchemas.CallToolResult has type StandardSchemaV1Sync<SpecTypeInputs['CallToolResult'], SpecTypes['CallToolResult']> (specTypeSchema.ts:238).
  2. StandardSchemaV1Sync['~standard']['validate'] is typed as (value: unknown, options?) => StandardSchemaV1.Result<Output> — no Promise branch (standardSchema.ts:127-129).
  3. So const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); if (result.issues === undefined) { result.value } typechecks with no await.
  4. Yet the @example at specTypeSchema.ts:268 reads const result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);, and docs/migration.md:510 / migration-SKILL.md:471 instruct the same.
  5. The new StandardSchemaV1Sync JSDoc says "without await" — directly contradicted by the example one file over.

How to fix

  1. In packages/core/src/types/specTypeSchema.examples.ts, drop the async wrapper and the await from the specTypeSchemas_basicUsage region.
  2. Run pnpm sync:snippets to regenerate the JSDoc fence in specTypeSchema.ts.
  3. In docs/migration.md (lines ~510-513) and docs/migration-SKILL.md (lines ~102, 471-472), drop await from the examples and change StandardSchemaV1<In, Out>StandardSchemaV1Sync<In, Out>.

This is a nit — non-blocking documentation polish, not a correctness bug — but it directly undercuts the PR's purpose if left as-is.

export type { SpecTypeName, SpecTypes } from '../../types/specTypeSchema.js';
export { isSpecType, specTypeSchemas } from '../../types/specTypeSchema.js';
export type { StandardSchemaV1, StandardSchemaWithJSON } from '../../util/standardSchema.js';
export type { StandardSchemaV1, StandardSchemaV1Sync, StandardSchemaWithJSON } from '../../util/standardSchema.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 changeset-bot already flagged the missing changeset, but note that the existing unreleased .changeset/spec-type-schema.md still says "specTypeSchemas.ContentBlock is a StandardSchemaV1<ContentBlock> validator" — that prose is now stale after this PR narrows entries to StandardSchemaV1Sync. Since spec-type-schema hasn't been consumed yet (it's absent from .changeset/pre.json's changesets array), the cleanest fix is to update that file's prose to say StandardSchemaV1Sync and mention the new export; otherwise add a fresh patch changeset for @modelcontextprotocol/core.

Extended reasoning...

What's missing and why it matters

This PR makes two user-visible changes to the public API of @modelcontextprotocol/core: it adds a new public type export StandardSchemaV1Sync at packages/core/src/exports/public/index.ts:143, and it narrows the published type of every specTypeSchemas entry from StandardSchemaV1<…> to StandardSchemaV1Sync<…> (packages/core/src/types/specTypeSchema.ts:238). Neither change is reflected in any changeset.

The stale-prose part the bot doesn't catch

The repo already has an unreleased changeset for the feature this PR amends. .changeset/spec-type-schema.md reads:

specTypeSchemas.ContentBlock is a StandardSchemaV1<ContentBlock> validator.

After this PR, specTypeSchemas.ContentBlock is typed as StandardSchemaV1Sync<ContentBlock> — the whole point of the change is that consumers no longer need to await the result. The changelog entry that ships to npm will therefore describe a looser type than the SDK actually exposes, and won't mention the new StandardSchemaV1Sync export at all. This is squarely the case REVIEW.md § Documentation & Changesets calls out: changeset prose that promises behavior the code no longer ships.

Why this isn't a pure duplicate of the bot comment

One verifier objected that changeset-bot already posted "⚠️ No Changeset found" and that re-flagging it adds nothing. The bot comment is correct as far as it goes, but it only checks whether the diff touches .changeset/*.md — it has no way to notice that an existing changeset's text has drifted from the implementation. The actionable finding here is the stale StandardSchemaV1<ContentBlock> sentence in spec-type-schema.md, which the bot cannot and did not surface. The refutation also argued REVIEW.md § Documentation & Changesets only covers added changeset files; but the rule's intent — "prose that promises behavior the code no longer ships" — applies just as much to a pending changeset whose described behavior is changed by a later PR before release.

Step-by-step proof

  1. .changeset/pre.json is in pre mode and its changesets array does not include "spec-type-schema", so that changeset has not yet been consumed into any published alpha — its text is still editable and will appear verbatim in the next release's changelog.
  2. .changeset/spec-type-schema.md line 6 literally contains `StandardSchemaV1<ContentBlock>`.
  3. After this PR, SchemaRecord at specTypeSchema.ts:238 is { readonly [K in SpecTypeName]: StandardSchemaV1Sync<…> }, so typeof specTypeSchemas.ContentBlock is StandardSchemaV1Sync<ContentBlock>, not StandardSchemaV1<ContentBlock>.
  4. git diff main...HEAD -- .changeset/ is empty for this PR (confirmed by changeset-bot on commit 34bccc9).
  5. Therefore the next changeset version run will publish a changelog line for specTypeSchemas that references the wrong type and omits the StandardSchemaV1Sync export.

Precedent

Type-only public-API changes in this repo routinely get changesets — e.g. .changeset/fix-transport-exact-optional-property-types.md is a pure type fix carrying '@modelcontextprotocol/core': patch, and spec-type-schema.md itself is the changeset for the feature being amended here.

Fix

Either (a) edit .changeset/spec-type-schema.md in place — change StandardSchemaV1<ContentBlock> to StandardSchemaV1Sync<ContentBlock> and add a clause noting that validate is synchronous / that StandardSchemaV1Sync is exported (cleanest, since the original feature is still unreleased per pre.json); or (b) add a new '@modelcontextprotocol/core': patch changeset describing the StandardSchemaV1Sync export and the narrowed specTypeSchemas entry type. Filed as a nit since it's release-process hygiene rather than a code defect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant