Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ pnpm-lock.yaml
# (fetch:spec-examples) or hand-built and frozen - byte-faithful artifacts.
packages/core/test/corpus/fixtures/

# Schema twins: raw upstream schema.json bytes (fetch:schema-twins), locked to
# manifest.json by sha256 in schemaTwinConformance - reformatting breaks the lock.
packages/core/test/corpus/schema-twins/

# Batch test cloned repos and results
packages/codemod/batch-test/repos
packages/codemod/batch-test/results
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"mcp"
],
"scripts": {
"fetch:schema-twins": "tsx scripts/fetch-schema-twins.ts",
"fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts",
"fetch:spec-types": "tsx scripts/fetch-spec-types.ts",
"sync:snippets": "tsx scripts/sync-snippets.ts",
Expand Down
12 changes: 0 additions & 12 deletions packages/client/etc/client.api.md

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

12 changes: 0 additions & 12 deletions packages/client/etc/client.stdio.api.md

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

11 changes: 7 additions & 4 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,6 @@ export class Client extends Protocol<ClientContext> {
this._serverCapabilities = result.capabilities;
this._serverVersion = result.serverInfo;
this._negotiatedProtocolVersion = result.protocolVersion;
// The negotiated version selects the wire codec for everything
// this connection sends/receives from here on (the negotiated
// version cashes out as the negotiated wire ERA - Q1-SD1).
bindWireVersion(this, result.protocolVersion);
// HTTP transports must set the protocol version in each header after initialization.
if (transport.setProtocolVersion) {
transport.setProtocolVersion(result.protocolVersion);
Expand All @@ -498,6 +494,13 @@ export class Client extends Protocol<ClientContext> {
method: 'notifications/initialized'
});

// The negotiated version selects the wire codec for everything
// this connection sends/receives from here on (the negotiated
// version cashes out as the negotiated wire ERA - Q1-SD1). Bound
// AFTER the initialized notification: the initialize EXCHANGE is
// the legacy handshake by definition and completes on that era.
bindWireVersion(this, result.protocolVersion);

// Set up list changed handlers now that we know server capabilities
if (this._pendingListChangedConfig) {
this._setupListChangedHandlers(this._pendingListChangedConfig);
Expand Down
101 changes: 40 additions & 61 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import type { LiftedWireMaterial, NarrowResultKey, WireCodec } from '../wire/cod
import {
bindRequestCodec,
codecForContext,
hasBoundWireVersion,
inboundCodecFor,
isSpecNotificationMethod,
isSpecRequestMethod,
Expand Down Expand Up @@ -877,10 +878,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
options?: RequestOptions
): Promise<StandardSchemaV1.InferOutput<T>>;
request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise<unknown> {
// Outbound codec resolution: lifecycle messages are bootstrap-pinned
// (they precede negotiation and self-identify their era); everything
// else rides the instance's negotiated era (legacy when unbound).
const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this);
const codec = this._resolveOutboundCodec(request.method);
this._assertOutboundRequestInEra(codec, request.method);
if (isStandardSchema(schemaOrOptions)) {
return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions);
Expand All @@ -899,6 +897,21 @@ export abstract class Protocol<ContextT extends BaseContext> {
* Methods outside the spec universe are consumer-owned extension methods
* and stay era-blind.
*/
/**
* Outbound codec resolution: BEFORE negotiation, lifecycle messages are
* bootstrap-pinned (they self-identify their era — `initialize` IS the
* legacy handshake, `server/discover` IS the modern probe); AFTER a wire
* version is bound, the negotiated era is authoritative for everything —
* a negotiated session never re-routes a method onto the other era.
*/
private _resolveOutboundCodec(method: string): WireCodec {
if (!hasBoundWireVersion(this)) {
const pinned = bootstrapOutboundCodec(method);
if (pinned) return pinned;
}
return outboundCodecFor(this);
}

private _assertOutboundRequestInEra(codec: WireCodec, method: string): void {
if (isSpecRequestMethod(method) && !codec.hasRequestMethod(method)) {
throw new SdkError(
Expand All @@ -919,7 +932,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
* dispatch time, like every other method-keyed binding.
*/
protected _requestWithNarrowSchema<R extends Result>(request: Request, narrow: NarrowResultKey, options?: RequestOptions): Promise<R> {
const codec = bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this);
const codec = this._resolveOutboundCodec(request.method);
this._assertOutboundRequestInEra(codec, request.method);
const schema = codec.narrowResultSchema(narrow);
if (!schema) {
Expand All @@ -943,12 +956,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
resultSchema: T,
options?: RequestOptions
): Promise<StandardSchemaV1.InferOutput<T>> {
return this._requestWithSchemaViaCodec(
bootstrapOutboundCodec(request.method) ?? outboundCodecFor(this),
request,
resultSchema,
options
);
return this._requestWithSchemaViaCodec(this._resolveOutboundCodec(request.method), request, resultSchema, options);
}

/**
Expand Down Expand Up @@ -1045,55 +1053,26 @@ export abstract class Protocol<ContextT extends BaseContext> {
return reject(response);
}

// Raw-first result discrimination (protocol revision
// 2026-07-28): inspect `resultType` BEFORE any schema
// validation, so a non-complete result can never be masked
// into a hollow success by a tolerant result schema (e.g.
// defaults filling in absent members).
let result = response.result;
if (isPlainObject(result) && result['resultType'] !== undefined) {
const rawResultType = result['resultType'];
if (typeof rawResultType !== 'string') {
// Defense in depth, not a reachable rejection today:
// the wire schema types `resultType` as a string, so
// message classification rejects a non-string carrier
// before it can reach this funnel (the request then
// hangs until timeout — the pre-existing failure mode
// for malformed responses). The arm stays so the
// raw-first check is self-contained if classification
// ever loosens.
return reject(
new SdkError(SdkErrorCode.InvalidResult, `Invalid result for ${request.method}: non-string resultType`, {
resultType: rawResultType
})
);
}
if (rawResultType !== 'complete') {
// Surface the discriminated kind; no retry. This arm
// is replaced by full multi-round-trip handling when
// the client driver lands.
return reject(
new SdkError(
SdkErrorCode.UnsupportedResultType,
`Unsupported result type '${rawResultType}' for ${request.method}`,
{ resultType: rawResultType, method: request.method }
)
);
}
// 'complete': the SDK consumes the wire discriminator;
// strip it before validation so consumers receive the
// public result shape.
const rest = { ...result };
delete rest['resultType'];
result = rest as Result;
// Codec decode hop — the structural V-1 home. The era codec
// owns the raw-first resultType postures (Q1-SD3):
// - 2026 era: REQUIRED discriminator; absent → typed error
// naming the spec violation; input_required → driver seam;
// unknown kind → invalid, no retry; complete → wire-exact
// parse then lift.
// - 2025 era: resultType is foreign vocabulary → strip-on-
// lift, then today's schema validation decides.
// Either way a non-complete body can never be masked into a
// hollow success by a tolerant result schema.
// Guarded: this callback runs synchronously inside
// `_onresponse`, so a throw out of the decode hop would
// otherwise propagate into the transport's onmessage instead
// of failing this request.
let decoded: ReturnType<WireCodec['decodeResult']>;
try {
decoded = codec.decodeResult(request.method, response.result);
} catch (error) {
return reject(error instanceof Error ? error : new Error(String(error)));
}

// Codec decode hop (the structural V-1 home): the era codec
// applies its raw-first posture before schema validation.
// NOTE (staging): the funnel block above predates the codec
// split and still runs first; it is removed when the
// 2026-era codec lands and the codecs own the postures.
const decoded = codec.decodeResult(request.method, result);
if (decoded.kind === 'invalid') {
return reject(decoded.error);
}
Expand All @@ -1108,7 +1087,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
})
);
}
result = decoded.result;
const result = decoded.result;

validateStandardSchema(resultSchema, result).then(parseResult => {
if (parseResult.success) {
Expand Down Expand Up @@ -1150,7 +1129,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
* Emits a notification, which is a one-way message that does not expect a response.
*/
async notification(notification: Notification, options?: NotificationOptions): Promise<void> {
return this._notificationViaCodec(bootstrapOutboundCodec(notification.method) ?? outboundCodecFor(this), notification, options);
return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options);
}

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/core/src/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,10 @@ They are reference-only test oracles: the comparison suites in `packages/core/te
3. **The bot proposes; it never auto-merges.** Automated refreshes always go through a pull request that a maintainer reviews and merges. No automation pushes anchor changes directly to `main` or merges its own PRs. A refresh PR that breaks the comparison suites is the desired
signal — it is fixed in that PR, not bypassed.

4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are ever checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same
commit. The anchor and its derived twins must never be out of sync at any commit on `main`. This clause becomes operative the day such generated artifacts are first vendored.
4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same commit.
The anchor and its derived twins must never be out of sync at any commit on `main`.

**This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core/test/corpus/schema-twins/` (`<revision>.schema.json` + `manifest.json` recording the source commit and content hashes). They are TEST-ONLY oracles consumed by the
schema-twin conformance lock (`test/wire/schemaTwinConformance.test.ts`) — never bundled, never imported by runtime code, and the JSON Schema engines stay optional peer dependencies. A refresh of `spec.types.<revision>.ts` must copy the matching upstream
`schema/<dir>/schema.json` (same spec commit) over the twin and update `manifest.json` in the same commit; the spec example corpus manifest (`test/corpus/fixtures/<revision>/manifest.json`) records its own source commit and follows the same atomicity rule when the examples
are re-vendored. The conformance lock failing after an anchor-only refresh is the desired loud signal of a missed twin update.
14 changes: 8 additions & 6 deletions packages/core/src/wire/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import type * as z from 'zod/v4';
import type { SdkError } from '../errors/sdkErrors.js';
import type { MessageClassification, RequestMetaEnvelope, Result } from '../types/types.js';
import { rev2025Codec } from './rev2025-11-25/codec.js';
import { rev2026Codec } from './rev2026-07-28/codec.js';

/** Wire eras with distinct vocabulary. */
export type WireEra = '2025-11-25' | '2026-07-28';
Expand Down Expand Up @@ -159,13 +160,9 @@ export interface WireCodec {
* 2026-era codec; `undefined`/unknown → legacy (the DV-13 default posture —
* hand-constructed instances and unclassified traffic are legacy-era).
*
* NOTE (staging): the 2026-era codec lands with Q1 increment-2 step 5; until
* then every version resolves to the 2025-era codec and behavior is
* byte-identical to the pre-split SDK.
*/
export function codecForVersion(version: string | undefined): WireCodec {
void version;
return rev2025Codec;
return version === MODERN_WIRE_REVISION ? rev2026Codec : rev2025Codec;
}

/**
Expand Down Expand Up @@ -195,7 +192,7 @@ export function isSpecNotificationMethod(method: string): boolean {
return ALL_CODECS.some(codec => codec.hasNotificationMethod(method));
}

const ALL_CODECS: readonly WireCodec[] = [rev2025Codec];
const ALL_CODECS: readonly WireCodec[] = [rev2025Codec, rev2026Codec];

/* ------------------------------------------------------------------------ *
* Internal binding channels.
Expand Down Expand Up @@ -236,6 +233,11 @@ export function outboundCodecFor(owner: object): WireCodec {
return codecForVersion(outboundWireVersion.get(owner));
}

/** Whether a negotiated wire version has been bound for this instance. */
export function hasBoundWireVersion(owner: object): boolean {
return outboundWireVersion.has(owner);
}

/**
* Resolve the codec for an INBOUND message: per-request classification wins
* (Q2), the instance's bound session version is the fallback for hand-wired
Expand Down
Loading
Loading