v2 backwards compat: specTypeSchema exported - synchronous StandardSchemaV1#2047
v2 backwards compat: specTypeSchema exported - synchronous StandardSchemaV1#2047KKonstantinov wants to merge 1 commit into
v2 backwards compat: specTypeSchema exported - synchronous StandardSchemaV1#2047Conversation
|
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
| }; | ||
|
|
||
| type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1<SpecTypeInputs[K], SpecTypes[K]> }; | ||
| type SchemaRecord = { readonly [K in SpecTypeName]: StandardSchemaV1Sync<SpecTypeInputs[K], SpecTypes[K]> }; |
There was a problem hiding this comment.
🟡 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@exampleblock onspecTypeSchemasshowsconst result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);. This fence is synced frompackages/core/src/types/specTypeSchema.examples.ts:16-24(thespecTypeSchemas_basicUsageregion), which is wrapped in anasync functionandawaits the call.docs/migration.md:510—const result = await specTypeSchemas.CallToolResult['~standard'].validate(value);docs/migration.md:513— "specTypeSchemas.X is aStandardSchemaV1<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>(aStandardSchemaV1<In, Out>)"docs/migration-SKILL.md:102— "for theStandardSchemaV1validator 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
- After this PR,
specTypeSchemas.CallToolResulthas typeStandardSchemaV1Sync<SpecTypeInputs['CallToolResult'], SpecTypes['CallToolResult']>(specTypeSchema.ts:238). StandardSchemaV1Sync['~standard']['validate']is typed as(value: unknown, options?) => StandardSchemaV1.Result<Output>— noPromisebranch (standardSchema.ts:127-129).- So
const result = specTypeSchemas.CallToolResult['~standard'].validate(untrusted); if (result.issues === undefined) { result.value }typechecks with noawait. - Yet the
@exampleat specTypeSchema.ts:268 readsconst result = await specTypeSchemas.CallToolResult['~standard'].validate(untrusted);, and docs/migration.md:510 / migration-SKILL.md:471 instruct the same. - The new
StandardSchemaV1SyncJSDoc says "withoutawait" — directly contradicted by the example one file over.
How to fix
- In
packages/core/src/types/specTypeSchema.examples.ts, drop theasyncwrapper and theawaitfrom thespecTypeSchemas_basicUsageregion. - Run
pnpm sync:snippetsto regenerate the JSDoc fence inspecTypeSchema.ts. - In
docs/migration.md(lines ~510-513) anddocs/migration-SKILL.md(lines ~102, 471-472), dropawaitfrom the examples and changeStandardSchemaV1<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'; |
There was a problem hiding this comment.
🟡 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.ContentBlockis aStandardSchemaV1<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 ".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
.changeset/pre.jsonis inpremode and itschangesetsarray 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..changeset/spec-type-schema.mdline 6 literally contains`StandardSchemaV1<ContentBlock>`.- After this PR,
SchemaRecordatspecTypeSchema.ts:238is{ readonly [K in SpecTypeName]: StandardSchemaV1Sync<…> }, sotypeof specTypeSchemas.ContentBlockisStandardSchemaV1Sync<ContentBlock>, notStandardSchemaV1<ContentBlock>. git diff main...HEAD -- .changeset/is empty for this PR (confirmed by changeset-bot on commit 34bccc9).- Therefore the next
changeset versionrun will publish a changelog line forspecTypeSchemasthat references the wrong type and omits theStandardSchemaV1Syncexport.
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.
Add
StandardSchemaV1Synctype sospecTypeSchemasentries expose synchronousvalidateMotivation and Context
specTypeSchemasexposes every MCP spec schema as aStandardSchemaV1. TheStandardSchemaV1interface definesvalidateas returningResult<Output> | Promise<Result<Output>>, because the Standard Schema spec supports async validation libraries. However, every entry inspecTypeSchemasis backed by Zod, which always validates synchronously -- thePromisevariant is never returned at runtime.This causes a real problem for consumers: calling
specTypeSchemas.X['~standard'].validate(v)and then accessing.issuesor.valueon the result produces a type error, because TypeScript thinks the result might be aPromise.How Has This Been Tested?
pnpm typecheck:all-- all packages typecheck cleanlypnpm test:all-- all 1532 tests pass across all packagesBreaking Changes
No.
StandardSchemaV1SyncextendsStandardSchemaV1, sospecTypeSchemasentries remain assignable anywhere aStandardSchemaV1is expected. The change only narrows the return type ofvalidatefromResult | Promise<Result>toResult.Types of changes
Checklist
Additional context
Three files changed (types only, no runtime changes):
packages/core/src/util/standardSchema.ts-- definesStandardSchemaV1Sync, which extendsStandardSchemaV1but narrowsvalidateto returnStandardSchemaV1.Result<Output>(noPromisevariant)packages/core/src/types/specTypeSchema.ts--SchemaRecordnow usesStandardSchemaV1Syncinstead ofStandardSchemaV1packages/core/src/exports/public/index.ts-- exportsStandardSchemaV1Syncso consumers can reference the typeAPIs that accept user-provided schemas (e.g.
McpServer.tool()) still useStandardSchemaV1/StandardSchemaWithJSON, so async validation libraries remain fully supported.