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/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/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..b29fab8a1 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1892,12 +1892,16 @@ 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(() => { 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 c77c803a9..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. @@ -427,16 +428,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 +456,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 +588,7 @@ export function extractSecretsFromStored( if (Object.keys(restOauth).length > 0) { stripped.oauth = restOauth; } else { - delete (stripped as unknown as Record).oauth; + delete stripped.oauth; } } 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/modern-tasks.ts b/test-servers/src/modern-tasks.ts index 3bb01dc68..bc7a6d773 100644 --- a/test-servers/src/modern-tasks.ts +++ b/test-servers/src/modern-tasks.ts @@ -321,6 +321,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"); 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) {