Skip to content
Merged
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
69 changes: 20 additions & 49 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1154,55 +1154,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 @@ -1217,7 +1188,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
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.
11 changes: 4 additions & 7 deletions packages/core/src/wire/codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import type {
ResultTypeMap
} 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 @@ -166,13 +167,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 All @@ -185,7 +182,7 @@ export function codecForVersion(version: string | undefined): WireCodec {
*/
export function classifiedWireEra(classification: MessageClassification): WireEra {
if (classification.revision !== undefined) return codecForVersion(classification.revision).era;
return classification.era === 'modern' ? MODERN_WIRE_REVISION : rev2025Codec.era;
return classification.era === 'modern' ? rev2026Codec.era : rev2025Codec.era;
}

/**
Expand All @@ -203,4 +200,4 @@ 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];
163 changes: 163 additions & 0 deletions packages/core/src/wire/rev2025-11-25/wireTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* 2025-era WIRE-VIEW types: the anchor-exact 2025-11-25 shapes for the names
* whose NEUTRAL public types deliberately follow the 2026-07-28 typing.
*
* This module is the visible home of the shared-tier ADJUDICATIONS that the
* old `@ts-expect-error` affordances used to suppress (Q1 increment 2): each
* override below names a field where the 2025 anchor and the neutral model
* disagree, states which side the neutral model follows, and is pinned both
* ways by the per-revision parity suite (spec.types.2025-11-25.test.ts
* compares THESE types against the frozen anchor exactly — zero affordances).
*
* RUNTIME NOTE (Q10-L2): the 2025-era runtime schemas are BEHAVIOR-FROZEN
* and deliberately stay tolerant-wider than these wire views where the
* neutral typing is wider (e.g. `experimental` values accept any JSONObject
* at parse). These types pin the WIRE-LEVEL shape contract against the
* anchor; they do not narrow runtime acceptance.
*
* Adjudication ledger (neutral follows 2026 unless stated):
* - `Tool.inputSchema`/`outputSchema` property values: 2025 wire `object`;
* neutral follows 2026 (`JSONValue`-capable open schema objects).
* - capability blobs (`experimental`, `sampling`, `elicitation`, `tasks`,
* `logging`, `completions`): 2025 wire `object`; neutral `JSONObject`.
* - `extensions` capability key: 2026-only; absent from the 2025 wire view.
* - `CreateMessageRequestParams.metadata`: 2025 wire `object`; neutral
* `JSONObject`.
* - `PromptArgument.title` / `PromptReference.title`: present on the 2025
* wire (BaseMetadata); the neutral schemas do not declare it and the
* strip-mode parse drops it (PRE-EXISTING runtime gap, recorded in the
* project baseline-bug log — do not silently change parse behavior here).
*/
import type {
CallToolRequest,
CancelTaskRequest,
ClientCapabilities,
CompleteRequest,
CreateMessageRequest,
CreateMessageRequestParams,
ElicitRequest,
GetPromptRequest,
GetTaskPayloadRequest,
GetTaskRequest,
InitializeRequest,
InitializeRequestParams,
InitializeResult,
ListPromptsRequest,
ListResourcesRequest,
ListResourceTemplatesRequest,
ListRootsRequest,
ListTasksRequest,
ListToolsRequest,
ListToolsResult,
PingRequest,
PromptArgument,
PromptReference,
ReadResourceRequest,
ServerCapabilities,
SetLevelRequest,
SubscribeRequest,
Tool,
UnsubscribeRequest
} from '../../types/types.js';

/** The 2025 anchor types blob values as bare `object`. */
type ObjectMap = { [key: string]: object };

/**
* Omit that survives loose (index-signature) source types: the plain `Omit`
* collapses named keys into the index signature (`Pick<T, string>`), which
* silently weakens the pins. Key-remapping preserves both.
*/
type OmitKnown<T, K extends PropertyKey> = { [P in keyof T as P extends K ? never : P]: T[P] };

/** 2025 wire shape of tool input/output schemas (property values are `object`). */
export type Wire2025ToolIOSchema = {
$schema?: string;
type: 'object';
properties?: ObjectMap;
required?: string[];
};

export type Wire2025Tool = OmitKnown<Tool, 'inputSchema' | 'outputSchema'> & {
inputSchema: Wire2025ToolIOSchema;
outputSchema?: Wire2025ToolIOSchema;
};

export type Wire2025ListToolsResult = OmitKnown<ListToolsResult, 'tools'> & { tools: Wire2025Tool[] };

export type Wire2025ClientCapabilities = OmitKnown<
ClientCapabilities,
'extensions' | 'experimental' | 'sampling' | 'elicitation' | 'tasks'
> & {
experimental?: ObjectMap;
sampling?: { context?: object; tools?: object };
elicitation?: { form?: object; url?: object };
tasks?: {
list?: object;
cancel?: object;
requests?: { sampling?: { createMessage?: object }; elicitation?: { create?: object } };
};
};

export type Wire2025ServerCapabilities = OmitKnown<
ServerCapabilities,
'extensions' | 'experimental' | 'logging' | 'completions' | 'tasks'
> & {
experimental?: ObjectMap;
logging?: object;
completions?: object;
tasks?: {
list?: object;
cancel?: object;
requests?: { tools?: { call?: object } };
};
};

export type Wire2025InitializeRequestParams = OmitKnown<InitializeRequestParams, 'capabilities'> & {
capabilities: Wire2025ClientCapabilities;
};

export type Wire2025InitializeRequest = OmitKnown<InitializeRequest, 'params'> & { params: Wire2025InitializeRequestParams };

export type Wire2025InitializeResult = OmitKnown<InitializeResult, 'capabilities'> & { capabilities: Wire2025ServerCapabilities };

export type Wire2025CreateMessageRequestParams = OmitKnown<CreateMessageRequestParams, 'metadata' | 'tools'> & {
metadata?: object;
tools?: Wire2025Tool[];
};

export type Wire2025CreateMessageRequest = OmitKnown<CreateMessageRequest, 'params'> & { params: Wire2025CreateMessageRequestParams };

/** 2025 wire: `title` is a declared BaseMetadata member (the neutral schemas do not model it — see ledger above). */
export type Wire2025PromptArgument = PromptArgument & { title?: string };
export type Wire2025PromptReference = PromptReference & { title?: string };

/** The 2025 wire role unions with the adjudicated members substituted. */
export type Wire2025ClientRequestView =
| PingRequest
| Wire2025InitializeRequest
| CompleteRequest
| SetLevelRequest
| GetPromptRequest
| ListPromptsRequest
| ListResourcesRequest
| ListResourceTemplatesRequest
| ReadResourceRequest
| SubscribeRequest
| UnsubscribeRequest
| CallToolRequest
| ListToolsRequest
| GetTaskRequest
| GetTaskPayloadRequest
| ListTasksRequest
| CancelTaskRequest;

export type Wire2025ServerRequestView =
| PingRequest
| Wire2025CreateMessageRequest
| ElicitRequest
| ListRootsRequest
| GetTaskRequest
| GetTaskPayloadRequest
| ListTasksRequest
| CancelTaskRequest;
Loading
Loading