From 9876839d81b23eaa2467ce71c0be01de29aa6283 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 23 Jul 2026 22:19:48 -0400 Subject: [PATCH 1/4] =?UTF-8?q?core:=20refactor=20serverList.ts=20object?= =?UTF-8?q?=E2=86=94Record=20casts=20into=20typed=20helper=20(#1690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the four pre-existing `as unknown as Record` / `as unknown as MCPServerConfig` casts in core/mcp/serverList.ts: - stripInspectorFields: rewrite as clone + `delete` over INSPECTOR_FIELD_KEYS. Every Inspector-extension key is optional on StoredMCPServer, so the result is still (a subtype of) MCPServerConfig with no cast. - oauth-strip in extractSecretsFromStored: `oauth` is optional, so a plain `delete stripped.oauth` is legal — drop the cast. - mcpConfigToServerEntries: route the one genuinely-needed widening through a single documented `toRecord(value: object)` helper. Taking `object` makes the single `as Record` legal (no TS2352), so the double cast disappears from the call site. Net: zero `as unknown as` in serverList.ts; the one remaining structural cast lives inside the audited toRecord helper. Behavior-neutral — existing coverage (79 serverList tests) unchanged; full `npm run ci` passes. Closes #1690 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- core/mcp/serverList.ts | 46 ++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index c77c803a9..e34350125 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -420,6 +420,20 @@ export const INSPECTOR_FIELD_KEYS = new Set( Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[], ); +/** + * Widen a typed config object to a generic string-keyed record so its keys can + * be iterated/written generically. `StoredMCPServer` / `MCPServerConfig` have + * no index signature, so a direct `value as Record` at a call + * site is a TS2352 error that would otherwise force an `as unknown as` double + * cast. Taking the argument as the general `object` type makes the single `as` + * legal (`Record` is assignable to `object`), so this one + * audited spot owns the widening and the double casts stay out of the call + * sites below. Purely a structural view of the same object — no runtime effect. + */ +function toRecord(value: object): Record { + return value as Record; +} + /** * Strip the Inspector-extension fields off a `StoredMCPServer` so the * remainder is the pure SDK config shape the PUT route's preserve path @@ -427,16 +441,17 @@ export const INSPECTOR_FIELD_KEYS = new Set( * new extension field doesn't silently leak through this slice — the * `satisfies` constraint above forces the map update, which propagates * here. + * + * Every Inspector-extension key is optional on `StoredMCPServer`, so deleting + * them off a clone leaves a value still typed as (a subtype of) + * `MCPServerConfig` — no cast needed. */ export function stripInspectorFields(stored: StoredMCPServer): MCPServerConfig { - const out: Record = {}; - for (const [k, v] of Object.entries( - stored as unknown as Record, - )) { - if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) continue; - out[k] = v; + const out = { ...stored }; + for (const key of INSPECTOR_FIELD_KEYS) { + delete out[key]; } - return out as unknown as MCPServerConfig; + return out; } /** @@ -454,16 +469,13 @@ export function mcpConfigToServerEntries(config: MCPConfig): ServerEntry[] { // `InspectorServerSettings` only. const inspectorFields: StoredInspectorFields = {}; const sdkOnly: Record = {}; - // Widen the typed config object to a generic record to iterate its keys. - // `StoredMCPServer` has no index signature, so TS requires the `unknown` - // step (`as Record` alone is TS2352). This is a plain - // structural widening, not an SDK-shape workaround. (Pre-existing pattern, - // also at the `serverEntryToStored` / oauth-strip casts in this file.) - for (const [k, v] of Object.entries( - raw as unknown as Record, - )) { + // Partition the entry's keys into Inspector-extension vs SDK-only. Both + // `raw` and `inspectorFields` are widened through `toRecord` (see its doc) + // so the generic key iteration/assignment needs no per-site cast. + const inspectorRecord = toRecord(inspectorFields); + for (const [k, v] of Object.entries(toRecord(raw))) { if (INSPECTOR_FIELD_KEYS.has(k as keyof StoredInspectorFields)) { - (inspectorFields as Record)[k] = v; + inspectorRecord[k] = v; } else { sdkOnly[k] = v; } @@ -589,7 +601,7 @@ export function extractSecretsFromStored( if (Object.keys(restOauth).length > 0) { stripped.oauth = restOauth; } else { - delete (stripped as unknown as Record).oauth; + delete stripped.oauth; } } From 6a3afa4a2dcf761c4e64eb431e2aa309990ddd75 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 23 Jul 2026 22:58:00 -0400 Subject: [PATCH 2/4] core+web+test-servers: eliminate/justify remaining `as unknown as` casts (#1690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codebase-wide follow-on to the serverList refactor: audit every `as unknown as` double cast outside tests and either remove it or ensure it carries a justifying comment (the review bar is "no *unjustified* double cast"). Removed (widened via a shared helper or narrowed to a single cast): - Promote the serverList `toRecord(value: object)` widening helper to core/json/jsonUtils.ts and reuse it at core/mcp/node/config.ts (normalize loop), core/mcp/remote/node/server.ts (pino level dispatch), and clients/web/src/App.tsx (window global lookup). - inspectorClient.rawWireRequest: type the frame as `JSONRPCRequest` and narrow only `params` with a single structural cast (the SDK's `_meta`-typed params is what blocked direct assignment). - toolOutputValidation: SDK `outputSchema` → `JsonSchemaType` is a single cast. - test-server-fixtures: the optional `task` handle is reachable with a single cast (target field is optional). Justified in place (genuinely unavoidable — private SDK internals, partial interface stubs, third-party/nominal bridges): the inspectorClient private-map accesses, resourceContext + tokenAuthProvider partial `OAuthClientProvider` stubs, the ext-apps peer bridge, modern/legacy `_requestHandlers` bypasses, and the happy-dom `matchMedia` test mock. Behavior-neutral. Full `npm run ci` passes (validate, ≥90 coverage gate, smoke, Storybook). Refs #1690 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- clients/web/src/App.tsx | 5 ++-- clients/web/src/test/setup.ts | 3 +++ core/auth/ema/resourceContext.ts | 4 ++++ core/json/jsonUtils.ts | 16 +++++++++++++ core/mcp/inspectorClient.ts | 28 +++++++++++++++-------- core/mcp/node/config.ts | 5 ++-- core/mcp/remote/node/server.ts | 3 ++- core/mcp/remote/node/tokenAuthProvider.ts | 6 +++++ core/mcp/serverList.ts | 15 +----------- core/mcp/toolOutputValidation.ts | 8 ++++--- test-servers/src/test-server-fixtures.ts | 9 ++++++-- 11 files changed, 66 insertions(+), 36 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index ee7c978ac..2e61e1125 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -21,6 +21,7 @@ import type { Tool, } from "@modelcontextprotocol/client"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { toRecord } from "@inspector/core/json/jsonUtils.js"; import { getServerType } from "@inspector/core/mcp/config.js"; import type { InspectorClientEventMap, @@ -244,9 +245,7 @@ function getAuthToken(): string | undefined { // ignore — see note above } }; - const fromGlobal = (window as unknown as Record)[ - INSPECTOR_API_TOKEN_GLOBAL - ]; + const fromGlobal = toRecord(window)[INSPECTOR_API_TOKEN_GLOBAL]; if (typeof fromGlobal === "string" && fromGlobal) { persist(fromGlobal); return fromGlobal; diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index 004f10a91..871993ff1 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -70,6 +70,9 @@ Object.defineProperty(globalThis, "fetch", { Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, + // Partial `matchMedia` mock: happy-dom doesn't implement it, and the members + // below are all the app touches. The stub omits the rest of `MediaQueryList`, + // so the double cast bridges the deliberately-incomplete shape. value: (query: string): MediaQueryList => ({ matches: /prefers-reduced-motion/.test(query), diff --git a/core/auth/ema/resourceContext.ts b/core/auth/ema/resourceContext.ts index 0ed0e43b5..9ca48f88b 100644 --- a/core/auth/ema/resourceContext.ts +++ b/core/auth/ema/resourceContext.ts @@ -50,6 +50,10 @@ export async function discoverEmaResourceContext( const resourceAsUrl = getAuthorizationServerUrl(serverUrl, resourceMetadata); const resourceUrl = await selectResourceURL( serverUrl, + // `selectResourceURL` only reads `clientMetadata.scope` off the provider, so + // we pass a minimal stub carrying just that. A partial literal has too little + // overlap with the full `OAuthClientProvider` interface for a single cast, so + // the double cast bridges the deliberately-incomplete shape here. { clientMetadata: { scope: configuredScope ?? "", diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index d9a099c61..7366bb9cb 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -14,6 +14,22 @@ export type JsonValue = export type JsonObject = { [key: string]: JsonValue }; +/** + * Widen a typed object to a generic string-keyed record so its keys can be + * iterated or read/written generically. Many of the project's config/SDK types + * (`StoredMCPServer`, `MCPServerConfig`, `pino.Logger`, DOM `Window`, …) have no + * index signature, so a direct `value as Record` at a call + * site is a TS2352 error that would otherwise force an `as unknown as` double + * cast. Taking the argument as the general `object` type makes the single `as` + * legal — `Record` is assignable to `object`, so the two types + * sufficiently overlap — letting this one audited spot own the widening while + * the double casts stay out of the call sites. Purely a structural view of the + * same object; no runtime effect. + */ +export function toRecord(value: object): Record { + return value as Record; +} + /** * Simple schema type for parameter conversion */ diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index ddb751416..54d478508 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1829,7 +1829,8 @@ export class InspectorClient extends InspectorClientEventTarget { // session, so this is always set; the constant is a defensive fallback. /* v8 ignore next -- fallback only if getProtocolVersion() is unset, which can't happen on the connected modern session this runs on. */ - const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; + const protocolVersion = + this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; return { ...params, _meta: { @@ -1861,9 +1862,7 @@ export class InspectorClient extends InspectorClientEventTarget { ...message, result: { resultType: "complete", - content: [ - { type: "text", text: `Modern task ${task.taskId} created` }, - ], + content: [{ type: "text", text: `Modern task ${task.taskId} created` }], _meta: { [MODERN_TASK_HANDLE_META]: task }, }, }; @@ -1892,17 +1891,23 @@ export class InspectorClient extends InspectorClientEventTarget { throw new Error("Client is not connected"); } const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`; - const message = { - jsonrpc: "2.0" as const, + // `params` is an arbitrary caller-supplied record; the SDK types request + // params with a specific optional `_meta` shape it can't satisfy, so widen + // it with a single structural cast. Typing `message` as `JSONRPCRequest` + // (a `JSONRPCMessage` member) then needs no further cast. + const message: JSONRPCRequest = { + jsonrpc: "2.0", id, method, - params, - } as unknown as JSONRPCMessage; + params: params as JSONRPCRequest["params"], + }; const timeoutMs = this.requestTimeout ?? 30_000; const raw = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRawWireRequests.delete(id); - reject(new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`)); + reject( + new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`), + ); }, timeoutMs); this.pendingRawWireRequests.set(id, { resolve, reject, timer }); transport.send(message).catch((err: unknown) => { @@ -4439,7 +4444,10 @@ export class InspectorClient extends InspectorClientEventTarget { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. - if (isTerminalStatus(this.status) || this.subscribedResources.size === 0) { + if ( + isTerminalStatus(this.status) || + this.subscribedResources.size === 0 + ) { return; } this.refreshModernSubscription(true).catch(() => diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index 7d05b5d34..9908eb36a 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -9,6 +9,7 @@ import type { StreamableHttpServerConfig, } from "../types.js"; import { normalizeServerType } from "../serverList.js"; +import { toRecord } from "../../json/jsonUtils.js"; /** * Options object passed to resolveServerConfigs by runners (parsed from argv). @@ -140,9 +141,7 @@ function loadMcpServersConfig( const normalizedServers: Record = {}; for (const [name, raw] of Object.entries(config.mcpServers)) { - normalizedServers[name] = normalizeServerType( - raw as unknown as Record & { type?: string }, - ); + normalizedServers[name] = normalizeServerType(toRecord(raw)); } return { ...config, mcpServers: normalizedServers }; } catch (error) { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index eec3f5d56..ce7f085dc 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -58,6 +58,7 @@ import { storedFieldsToInspectorSettings, stripInspectorFields, } from "../../serverList.js"; +import { toRecord } from "../../../json/jsonUtils.js"; import { resolveImportSource } from "../../import/resolveSource.js"; import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; @@ -298,7 +299,7 @@ function forwardLogEvent( logEvent: Partial, ): void { const levelLabel = (logEvent?.level?.label ?? "info").toLowerCase(); - const method = (logger as unknown as Record)[levelLabel]; + const method = toRecord(logger)[levelLabel]; if (typeof method !== "function") return; const bindings = Object.assign( diff --git a/core/mcp/remote/node/tokenAuthProvider.ts b/core/mcp/remote/node/tokenAuthProvider.ts index 2bf8a9dfc..2963a45eb 100644 --- a/core/mcp/remote/node/tokenAuthProvider.ts +++ b/core/mcp/remote/node/tokenAuthProvider.ts @@ -93,6 +93,12 @@ export function createRemoteAuthProvider( state() { return ""; }, + // This provider implements only the members the remote backend transport + // exercises (token get/save, client info, non-interactive redirect); the + // interactive-only members the SDK never calls on the server side are stubbed + // with narrower shapes (e.g. `codeVerifier()` returns `undefined`, not the + // interface's `string | Promise`), so it can't be assigned to the + // full `OAuthClientProvider` without the double cast. } as unknown as OAuthClientProvider; return { diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index e34350125..d46a31e67 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -27,6 +27,7 @@ import { SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, } from "../auth/secret-fields.js"; +import { toRecord } from "../json/jsonUtils.js"; // The full set of valid `type` discriminator values, used to reject anything // else read off disk so unknown strings can't propagate to narrowing sites. @@ -420,20 +421,6 @@ export const INSPECTOR_FIELD_KEYS = new Set( Object.keys(INSPECTOR_FIELD_KEY_MAP) as (keyof StoredInspectorFields)[], ); -/** - * Widen a typed config object to a generic string-keyed record so its keys can - * be iterated/written generically. `StoredMCPServer` / `MCPServerConfig` have - * no index signature, so a direct `value as Record` at a call - * site is a TS2352 error that would otherwise force an `as unknown as` double - * cast. Taking the argument as the general `object` type makes the single `as` - * legal (`Record` is assignable to `object`), so this one - * audited spot owns the widening and the double casts stay out of the call - * sites below. Purely a structural view of the same object — no runtime effect. - */ -function toRecord(value: object): Record { - return value as Record; -} - /** * Strip the Inspector-extension fields off a `StoredMCPServer` so the * remainder is the pure SDK config shape the PUT route's preserve path diff --git a/core/mcp/toolOutputValidation.ts b/core/mcp/toolOutputValidation.ts index add2c81e2..c626923b4 100644 --- a/core/mcp/toolOutputValidation.ts +++ b/core/mcp/toolOutputValidation.ts @@ -34,9 +34,11 @@ export function validateToolOutput( : `Tool "${tool.name}" declares an output schema but returned no structured content`; } try { - const validate = provider.getValidator( - tool.outputSchema as unknown as JsonSchemaType, - ); + // `tool.outputSchema` is the SDK's own object-schema shape; `JsonSchemaType` + // is the validator provider's third-party JSON Schema interface. The two are + // structurally compatible but nominally unrelated, so a cast is required to + // bridge them here. + const validate = provider.getValidator(tool.outputSchema as JsonSchemaType); const validation = validate(structured); return validation.valid ? undefined : validation.errorMessage; } catch { diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 3e9a87345..fff1d5652 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -2041,7 +2041,9 @@ async function runTaskExecution(params: RunTaskExecutionParams): Promise { ? z.union([ElicitResultSchema, CreateTaskResultSchema]) : ElicitResultSchema) as typeof ElicitResultSchema, ); - const elicitWithTask = elicitResponse as unknown as { + // The union result may carry a `task` handle that `ElicitResult` doesn't + // model. That field is optional, so a single narrowing cast reaches it. + const elicitWithTask = elicitResponse as { task?: { taskId: string }; }; if (receiverTaskTtl != null && elicitWithTask?.task) { @@ -2091,7 +2093,10 @@ async function runTaskExecution(params: RunTaskExecutionParams): Promise { ? z.union([CreateMessageResultSchema, CreateTaskResultSchema]) : CreateMessageResultSchema) as typeof CreateMessageResultSchema, ); - const samplingWithTask = samplingResponse as unknown as { + // The union result may carry a `task` handle that `CreateMessageResult` + // doesn't model. That field is optional, so a single narrowing cast + // reaches it. + const samplingWithTask = samplingResponse as { task?: { taskId: string }; }; if (receiverTaskTtl != null && samplingWithTask?.task) { From cc187ea9e8df2d58597fce7b61d2f417d1e86e81 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 23 Jul 2026 23:08:02 -0400 Subject: [PATCH 3/4] address @claude review nits: consistent cast justifications + drop reflow noise (#1690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test-servers/src/modern-tasks.ts: add an inline justification above the `_requestHandlers` double cast, mirroring the sibling in composable-test-server.ts (was leaning on the function JSDoc only). - Annotate the partial-`AppBridge` mock double casts in the story fixtures (AppsScreen ×2, AppRenderer, InspectorView) so no non-test-file double cast is unjustified. - inspectorClient.ts: revert unrelated Prettier reflow hunks (protocolVersion, task-content array, timeout-reject wrap, reconnect guard) so the diff is the single rawWireRequest cast change only. Behavior-neutral (comment/format-only). `npm run ci` green apart from a known flaky redaction test whose assertion collided with a random request id. Refs #1690 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- .../elements/AppRenderer/AppRenderer.stories.tsx | 2 ++ .../screens/AppsScreen/AppsScreen.stories.tsx | 4 ++++ .../views/InspectorView/InspectorView.stories.tsx | 2 ++ test-servers/src/modern-tasks.ts | 12 +++++++++++- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx index ce398e377..b1df1c0bc 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx @@ -25,6 +25,8 @@ function createMockBridge(): AppBridge { removeEventListener: () => {}, teardownResource: async () => ({}), close: async () => {}, + // Partial mock: implements only the `AppBridge` members the renderer + // exercises; the double cast bridges the deliberately-incomplete shape. } as unknown as AppBridge; } diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx index 8f31f3561..84061cad6 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.stories.tsx @@ -39,6 +39,8 @@ function createMockBridge(): AppBridge { close: async () => {}, addEventListener: () => {}, removeEventListener: () => {}, + // Partial mock: implements only the `AppBridge` members the screen + // exercises; the double cast bridges the deliberately-incomplete shape. } as unknown as AppBridge; } @@ -228,6 +230,8 @@ export const EchoRunning: Story = { // Simulate the view finishing initialization shortly after AppRenderer // registers its `initialized` listener; sendToolInput then flushes. setTimeout(() => onInitialized?.(), 20); + // Partial mock (only the members this story drives); the double cast + // bridges the deliberately-incomplete shape. return bridge as unknown as AppBridge; }, []); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 7dc70052d..5b2187264 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -46,6 +46,8 @@ const noopBridgeFactory: BridgeFactory = () => sendToolCancelled: async () => {}, teardownResource: async () => ({}), close: async () => {}, + // Partial mock: implements only the `AppBridge` members the view exercises; + // the double cast bridges the deliberately-incomplete shape. }) as unknown as AppBridge; // MCP App tools — `isAppTool` detects these via `_meta.ui.resourceUri`, diff --git a/test-servers/src/modern-tasks.ts b/test-servers/src/modern-tasks.ts index 3bb01dc68..15531921e 100644 --- a/test-servers/src/modern-tasks.ts +++ b/test-servers/src/modern-tasks.ts @@ -41,7 +41,11 @@ const DEFAULT_POLL_INTERVAL_MS = 500; const SIMPLE_WORKING_POLLS = 2; type ModernTaskStatus = - "working" | "input_required" | "completed" | "failed" | "cancelled"; + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; interface ModernTaskEntry { taskId: string; @@ -321,6 +325,12 @@ export function wireModernTaskHandlers( runtime: ModernTaskRuntime, ): void { const lowLevel = mcpServer.server; + // Reach the private `_requestHandlers` map (`RawHandlerHost`) to register a + // raw `tools/call` handler that skips the SDK's result-schema validation, so a + // task-augmented `CreateTaskResult` gets through. Mirrors the client-side + // bypass in `core/mcp/inspectorClient.ts` and the sibling in + // `composable-test-server.ts`; both go away when the SDK models the tasks + // extension natively. const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers; const sdkToolsCall = registry.get("tools/call"); From 62c02643c83ad115723b2889f2c06d8d4a94e207 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 23 Jul 2026 23:15:00 -0400 Subject: [PATCH 4/4] actually revert the reflow noise this time (#1690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reflow reverts in cc187ea were silently undone by a `prettier --write` I ran on the same file right after, so the four Prettier-only hunks from 6a3afa4a were still in the diff (caught in re-review). Revert them for real now — and without re-running Prettier on the file: - core/mcp/inspectorClient.ts: restore the protocolVersion line, task-content array, timeout-reject call, and reconnect guard to their origin/v2/main form, so the file's diff is only the single rawWireRequest cast change. - test-servers/src/modern-tasks.ts: restore the ModernTaskStatus union to its one-line form (the leading-`|` reflow was also Prettier churn), leaving only the new `_requestHandlers` justification comment. Formatting-only; `npm run validate` green. Refs #1690 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --- core/mcp/inspectorClient.ts | 16 ++++++---------- test-servers/src/modern-tasks.ts | 6 +----- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 54d478508..b29fab8a1 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1829,8 +1829,7 @@ export class InspectorClient extends InspectorClientEventTarget { // session, so this is always set; the constant is a defensive fallback. /* v8 ignore next -- fallback only if getProtocolVersion() is unset, which can't happen on the connected modern session this runs on. */ - const protocolVersion = - this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; + const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION; return { ...params, _meta: { @@ -1862,7 +1861,9 @@ export class InspectorClient extends InspectorClientEventTarget { ...message, result: { resultType: "complete", - content: [{ type: "text", text: `Modern task ${task.taskId} created` }], + content: [ + { type: "text", text: `Modern task ${task.taskId} created` }, + ], _meta: { [MODERN_TASK_HANDLE_META]: task }, }, }; @@ -1905,9 +1906,7 @@ export class InspectorClient extends InspectorClientEventTarget { const raw = await new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingRawWireRequests.delete(id); - reject( - new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`), - ); + reject(new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`)); }, timeoutMs); this.pendingRawWireRequests.set(id, { resolve, reject, timer }); transport.send(message).catch((err: unknown) => { @@ -4444,10 +4443,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. - if ( - isTerminalStatus(this.status) || - this.subscribedResources.size === 0 - ) { + if (isTerminalStatus(this.status) || this.subscribedResources.size === 0) { return; } this.refreshModernSubscription(true).catch(() => diff --git a/test-servers/src/modern-tasks.ts b/test-servers/src/modern-tasks.ts index 15531921e..bc7a6d773 100644 --- a/test-servers/src/modern-tasks.ts +++ b/test-servers/src/modern-tasks.ts @@ -41,11 +41,7 @@ const DEFAULT_POLL_INTERVAL_MS = 500; const SIMPLE_WORKING_POLLS = 2; type ModernTaskStatus = - | "working" - | "input_required" - | "completed" - | "failed" - | "cancelled"; + "working" | "input_required" | "completed" | "failed" | "cancelled"; interface ModernTaskEntry { taskId: string;