Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # core/mcp/node/transport.ts
This commit is contained in:
commit
baf4fe954f
16 changed files with 9048 additions and 1271 deletions
|
|
@ -36,6 +36,7 @@ import type {
|
|||
ResourceSubscriptionStreamState,
|
||||
ToolCallInvocation,
|
||||
ExcludedTool,
|
||||
RequestMetadata,
|
||||
} from "../types.js";
|
||||
import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import type { MalformedListItem } from "../listSalvage.js";
|
||||
|
|
@ -145,25 +146,25 @@ export class FakeInspectorClient
|
|||
listAllTools = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}) => ({ tools: drainPages(this.toolPages, "tools") }),
|
||||
);
|
||||
listAllPrompts = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}) => ({ prompts: drainPages(this.promptPages, "prompts") }),
|
||||
);
|
||||
listAllResources = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}) => ({ resources: drainPages(this.resourcePages, "resources") }),
|
||||
);
|
||||
listAllResourceTemplates = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}) => ({
|
||||
resourceTemplates: drainPages(
|
||||
this.resourceTemplatePages,
|
||||
|
|
@ -224,7 +225,7 @@ export class FakeInspectorClient
|
|||
_argumentName: string,
|
||||
_argumentValue: string,
|
||||
_context?: Record<string, string>,
|
||||
_metadata?: Record<string, string>,
|
||||
_metadata?: RequestMetadata,
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean }> => ({
|
||||
values: [],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { Client } from "@modelcontextprotocol/client";
|
||||
// The protocol's own schemas for the reserved `_meta` members, so the client
|
||||
// validates against the SDK rather than a restatement of it that can drift.
|
||||
import {
|
||||
ProgressTokenSchema,
|
||||
RequestMetaSchema,
|
||||
} from "@modelcontextprotocol/core";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
StderrLogEntry,
|
||||
|
|
@ -17,6 +23,7 @@ import type {
|
|||
PendingRequestOrigin,
|
||||
ResourceSubscriptionStreamState,
|
||||
ExcludedTool,
|
||||
RequestMetadata,
|
||||
} from "./types.js";
|
||||
import {
|
||||
scanXMcpHeaderDeclarations,
|
||||
|
|
@ -356,8 +363,8 @@ function notificationMethodFromSchema(schema: unknown): string | undefined {
|
|||
interface ToolCallRequest {
|
||||
tool: Tool;
|
||||
args: Record<string, JsonValue>;
|
||||
generalMetadata?: Record<string, string>;
|
||||
toolSpecificMetadata?: Record<string, string>;
|
||||
generalMetadata?: RequestMetadata;
|
||||
toolSpecificMetadata?: RequestMetadata;
|
||||
taskOptions?: { ttl?: number };
|
||||
options?: { skipOutputValidation?: boolean };
|
||||
}
|
||||
|
|
@ -368,6 +375,69 @@ interface ToolCallRequest {
|
|||
// server can't spin a tight zero-delay loop; a successful acknowledgement resets
|
||||
// the count, and past the cap we give up (mark the stream ended) rather than
|
||||
// retry a persistently-failing re-list forever.
|
||||
/**
|
||||
* A one-word name for a JSON value's type, for diagnostics that must not
|
||||
* disclose the value itself.
|
||||
*/
|
||||
function describeJsonType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `_meta.progressToken` value is one the protocol allows.
|
||||
*
|
||||
* Delegates to the SDK's own `ProgressTokenSchema` rather than restating it.
|
||||
* A hand-rolled check drifts: this was `Number.isInteger` until review pointed
|
||||
* out that zod 4's `.int()` rejects anything past `Number.MAX_SAFE_INTEGER`,
|
||||
* so an unsafe integer passed here and was rejected by the server instead.
|
||||
*/
|
||||
function isProgressToken(value: unknown): value is ProgressToken {
|
||||
return ProgressTokenSchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the reserved `_meta` members whose value the protocol will not accept.
|
||||
*
|
||||
* Most of `_meta` is arbitrary JSON (#1910), but a few keys are **reserved**
|
||||
* and carry their own schema — `progressToken` (a string or a safe integer;
|
||||
* zod's `.int()` rejects anything past `Number.MAX_SAFE_INTEGER`) and
|
||||
* `io.modelcontextprotocol/related-task` (`{ taskId: string }`) in the pinned
|
||||
* SDK. Before the widening every value was a string, so only `progressToken`
|
||||
* could even be wrong; now any of them can, and one bad reserved member makes a
|
||||
* conforming server reject the **whole request** rather than ignore the key.
|
||||
*
|
||||
* Validated with the SDK's `RequestMetaSchema` instead of a hand-maintained
|
||||
* list, which is the point: the schema is loose, so unknown keys pass through
|
||||
* untouched, and a member the SDK constrains in a later release is covered here
|
||||
* without anyone remembering to add it. Zod reports the offending top-level key
|
||||
* as `issue.path[0]`, so only that member is removed — siblings survive, and
|
||||
* the request stays well-formed because every reserved member is optional.
|
||||
*/
|
||||
function dropInvalidReservedMeta(
|
||||
meta: RequestMetadata,
|
||||
logger: InspectorLogger,
|
||||
): RequestMetadata {
|
||||
const result = RequestMetaSchema.safeParse(meta);
|
||||
if (result.success) return meta;
|
||||
|
||||
const cleaned = { ...meta };
|
||||
for (const issue of result.error.issues) {
|
||||
const key = issue.path[0];
|
||||
if (typeof key !== "string" || !(key in cleaned)) continue;
|
||||
// The key and the reason, never the value: `_meta` can carry credentials,
|
||||
// and this logger is persisted by real clients (the TUI writes it to
|
||||
// `~/.mcp-inspector/auth.log`).
|
||||
logger.warn(
|
||||
{ key, received: describeJsonType(cleaned[key]) },
|
||||
"Dropping reserved `_meta` member — it does not match the protocol schema.",
|
||||
);
|
||||
delete cleaned[key];
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
const MODERN_RECONNECT_BASE_MS = 500;
|
||||
const MODERN_RECONNECT_MAX_MS = 15_000;
|
||||
const MODERN_RECONNECT_MAX_ATTEMPTS = 8;
|
||||
|
|
@ -420,7 +490,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private progress: boolean;
|
||||
private resetTimeoutOnProgress: boolean;
|
||||
private requestTimeout: number | undefined;
|
||||
private defaultMetadata: Record<string, string> | undefined;
|
||||
private defaultMetadata: RequestMetadata | undefined;
|
||||
private serverSettings: InspectorServerSettings | undefined;
|
||||
private versionNegotiation: VersionNegotiationOptions;
|
||||
private status: ConnectionStatus = "disconnected";
|
||||
|
|
@ -1035,8 +1105,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
* injecting an empty `_meta` field.
|
||||
*/
|
||||
private mergeMeta(
|
||||
callMetadata?: Record<string, string>,
|
||||
): Record<string, string> | undefined {
|
||||
callMetadata?: RequestMetadata,
|
||||
): RequestMetadata | undefined {
|
||||
const defaults = this.defaultMetadata;
|
||||
// Modern-era per-request log level (#1629): stamp the opt-in `_meta` key on
|
||||
// every request so the server emits `notifications/message` on this
|
||||
|
|
@ -1052,7 +1122,29 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...(logMeta ?? {}),
|
||||
...(callMetadata ?? {}),
|
||||
};
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
// Reserved members carry their own schemas even though the rest of `_meta`
|
||||
// is arbitrary JSON, and one bad reserved value makes a conforming server
|
||||
// reject the whole request. See `dropInvalidReservedMeta`.
|
||||
const sanitized = dropInvalidReservedMeta(merged, this.logger);
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `progressToken` a caller stamped on its request metadata, when it is
|
||||
* one the SDK will accept.
|
||||
*
|
||||
* This governs only **callback correlation** — whether the client wires up an
|
||||
* `onprogress` for the request. Keeping an invalid value off the **wire** is
|
||||
* a separate concern handled in {@link mergeMeta}, which deletes the member
|
||||
* from the outgoing `_meta` so a conforming server is never sent something
|
||||
* `ProgressTokenSchema` rejects. The two use the same {@link isProgressToken}
|
||||
* predicate, so they cannot disagree about what counts as valid.
|
||||
*/
|
||||
private progressTokenOf(
|
||||
metadata: RequestMetadata | undefined,
|
||||
): ProgressToken | undefined {
|
||||
const token = metadata?.progressToken;
|
||||
return isProgressToken(token) ? token : undefined;
|
||||
}
|
||||
|
||||
private getRequestOptions(
|
||||
|
|
@ -1110,8 +1202,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
* so the merge/omit branch is defined once.
|
||||
*/
|
||||
private aggregateListParams(
|
||||
metadata?: Record<string, string>,
|
||||
): { _meta: Record<string, string> } | undefined {
|
||||
metadata?: RequestMetadata,
|
||||
): { _meta: RequestMetadata } | undefined {
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
return effectiveMeta ? { _meta: effectiveMeta } : undefined;
|
||||
}
|
||||
|
|
@ -3415,7 +3507,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listTools(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ tools: Tool[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -3433,7 +3525,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.client!.request(
|
||||
{ method: "tools/list", params },
|
||||
ListToolsResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
);
|
||||
const tools = [...(response.tools || [])];
|
||||
|
|
@ -3454,7 +3546,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listAllTools(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ tools: Tool[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -3606,7 +3698,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
* from it so every field except the entries is still validated. */
|
||||
resultSchema: z.ZodObject;
|
||||
aggregate: () => Promise<T[]>;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
/**
|
||||
* Filter applied to SALVAGED entries only, to reproduce a filter the SDK's
|
||||
* strict aggregate applies for us. Without it the fallback would return a
|
||||
|
|
@ -3684,7 +3776,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
itemsKey: string;
|
||||
itemSchema: z.ZodType<T>;
|
||||
resultSchema: z.ZodObject;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
err: unknown;
|
||||
}): Promise<T[]> {
|
||||
const pageSchema = lenientListPageSchema(resultSchema, itemsKey);
|
||||
|
|
@ -3735,7 +3827,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
: this.client!.request(
|
||||
{ method, params },
|
||||
pageSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method },
|
||||
);
|
||||
|
|
@ -3821,7 +3913,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
* stops the walk (non-converging-server guard, mirroring the SDK).
|
||||
*/
|
||||
async refreshExcludedTools(
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<ExcludedTool[]> {
|
||||
const excluded: ExcludedTool[] = [];
|
||||
// Gated to connections that actually exclude; otherwise this is a pure
|
||||
|
|
@ -3891,7 +3983,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
private async listToolsForScan(
|
||||
cursor: string | undefined,
|
||||
metadata: Record<string, string> | undefined,
|
||||
metadata: RequestMetadata | undefined,
|
||||
): Promise<{
|
||||
tools: Tool[];
|
||||
nextCursor?: string;
|
||||
|
|
@ -3934,7 +4026,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
: this.client!.request(
|
||||
{ method: "tools/list", params },
|
||||
pageSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: "tools/list" },
|
||||
);
|
||||
|
|
@ -4026,8 +4118,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
async callTool(
|
||||
tool: Tool,
|
||||
args: Record<string, JsonValue>,
|
||||
generalMetadata?: Record<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
generalMetadata?: RequestMetadata,
|
||||
toolSpecificMetadata?: RequestMetadata,
|
||||
taskOptions?: { ttl?: number },
|
||||
options?: { skipOutputValidation?: boolean },
|
||||
): Promise<ToolCallInvocation> {
|
||||
|
|
@ -4255,7 +4347,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
const convertedArgs = this.convertStringToolArgs(tool, args);
|
||||
|
||||
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
||||
const callMetadata: Record<string, string> | undefined =
|
||||
const callMetadata: RequestMetadata | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
|
@ -4268,7 +4360,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
const callParams: {
|
||||
name: string;
|
||||
arguments: Record<string, JsonValue>;
|
||||
_meta?: Record<string, string>;
|
||||
_meta?: RequestMetadata;
|
||||
task?: { ttl: number };
|
||||
} = {
|
||||
name: tool.name,
|
||||
|
|
@ -4280,7 +4372,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
const requestOptions = this.getRequestOptions(
|
||||
metadata?.progressToken,
|
||||
this.progressTokenOf(metadata),
|
||||
signal,
|
||||
);
|
||||
this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions);
|
||||
|
|
@ -4370,11 +4462,11 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private dispatchFailedToolCall(
|
||||
tool: Tool,
|
||||
args: Record<string, JsonValue>,
|
||||
generalMetadata: Record<string, string> | undefined,
|
||||
toolSpecificMetadata: Record<string, string> | undefined,
|
||||
generalMetadata: RequestMetadata | undefined,
|
||||
toolSpecificMetadata: RequestMetadata | undefined,
|
||||
errorMessage: string,
|
||||
): void {
|
||||
const callMetadata: Record<string, string> | undefined =
|
||||
const callMetadata: RequestMetadata | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
|
@ -4858,8 +4950,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
async callToolStream(
|
||||
tool: Tool,
|
||||
args: Record<string, JsonValue>,
|
||||
generalMetadata?: Record<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
generalMetadata?: RequestMetadata,
|
||||
toolSpecificMetadata?: RequestMetadata,
|
||||
taskOptions?: { ttl?: number },
|
||||
): Promise<ToolCallInvocation> {
|
||||
if (!this.client) {
|
||||
|
|
@ -4869,7 +4961,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
const convertedArgs = this.convertStringToolArgs(tool, args);
|
||||
|
||||
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
||||
const callMetadata: Record<string, string> | undefined =
|
||||
const callMetadata: RequestMetadata | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
|
@ -4905,7 +4997,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// attach one here either — doing so would request a progress token (and
|
||||
// emit requestorTaskProgress) for task calls only, bypassing the toggle
|
||||
// that governs every other call path.
|
||||
const requestOptions = this.getRequestOptions(metadata?.progressToken);
|
||||
const requestOptions = this.getRequestOptions(
|
||||
this.progressTokenOf(metadata),
|
||||
);
|
||||
// The task-augmented `tools/call` needs the same SEP-2243 mirroring as the
|
||||
// plain one — a strict modern server rejects it with -32020 otherwise.
|
||||
this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions);
|
||||
|
|
@ -5066,7 +5160,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return invocation;
|
||||
} catch (error) {
|
||||
// Merge general metadata with tool-specific metadata for error case
|
||||
const callMetadata: Record<string, string> | undefined =
|
||||
const callMetadata: RequestMetadata | undefined =
|
||||
generalMetadata || toolSpecificMetadata
|
||||
? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) }
|
||||
: undefined;
|
||||
|
|
@ -5096,7 +5190,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listResources(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ resources: Resource[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5110,7 +5204,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.client!.request(
|
||||
{ method: "resources/list", params },
|
||||
ListResourcesResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
|
|
@ -5127,7 +5221,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listAllResources(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ resources: Resource[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5158,7 +5252,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async readResource(
|
||||
uri: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<ResourceReadInvocation> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5177,7 +5271,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
"resources/read",
|
||||
params,
|
||||
ReadResourceResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: "resources/read" },
|
||||
);
|
||||
|
|
@ -5208,7 +5302,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
async readResourceFromTemplate(
|
||||
uriTemplate: string,
|
||||
params: Record<string, string>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<ResourceTemplateReadInvocation> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5270,7 +5364,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listResourceTemplates(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5285,7 +5379,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.client!.request(
|
||||
{ method: "resources/templates/list", params },
|
||||
ListResourceTemplatesResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: "resources/templates/list" },
|
||||
);
|
||||
|
|
@ -5303,7 +5397,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listAllResourceTemplates(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ resourceTemplates: ResourceTemplate[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5334,7 +5428,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listPrompts(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ prompts: Prompt[]; nextCursor?: string }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5348,7 +5442,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.client!.request(
|
||||
{ method: "prompts/list", params },
|
||||
ListPromptsResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
);
|
||||
return {
|
||||
|
|
@ -5365,7 +5459,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
*/
|
||||
async listAllPrompts(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ prompts: Prompt[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5398,7 +5492,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
async getPrompt(
|
||||
name: string,
|
||||
args?: Record<string, JsonValue>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<PromptGetInvocation> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
|
|
@ -5422,7 +5516,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
"prompts/get",
|
||||
params,
|
||||
GetPromptResultSchema,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{ method: "prompts/get", toolName: name },
|
||||
);
|
||||
|
|
@ -5461,7 +5555,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context?: Record<string, string>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean }> {
|
||||
if (!this.client) {
|
||||
return { values: [] };
|
||||
|
|
@ -5483,7 +5577,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
() =>
|
||||
this.client!.complete(
|
||||
params,
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||
),
|
||||
{
|
||||
method: "completion/complete",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
ResourceTemplateReadInvocation,
|
||||
ResourceSubscriptionStreamState,
|
||||
ExcludedTool,
|
||||
RequestMetadata,
|
||||
} from "./types.js";
|
||||
import type { MalformedListItem } from "./listSalvage.js";
|
||||
import type {
|
||||
|
|
@ -112,7 +113,7 @@ export interface InspectorClientEventMap {
|
|||
timestamp: Date;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
/** Non-fatal outputSchema mismatch detected on the skipOutputValidation path. */
|
||||
outputValidationError?: string;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
ToolCallInvocation,
|
||||
ResourceSubscriptionStreamState,
|
||||
ExcludedTool,
|
||||
RequestMetadata,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
CacheMode,
|
||||
|
|
@ -82,19 +83,19 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
// Paginated list methods used by managed/paged state stores
|
||||
listTools(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ tools: Tool[]; nextCursor?: string }>;
|
||||
listPrompts(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ prompts: Prompt[]; nextCursor?: string }>;
|
||||
listResources(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ resources: Resource[]; nextCursor?: string }>;
|
||||
listResourceTemplates(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }>;
|
||||
listRequestorTasks(
|
||||
cursor?: string,
|
||||
|
|
@ -126,41 +127,41 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
// `cacheMode` uses the SDK default ('use').
|
||||
listAllTools(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ tools: Tool[] }>;
|
||||
listAllPrompts(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ prompts: Prompt[] }>;
|
||||
listAllResources(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ resources: Resource[] }>;
|
||||
listAllResourceTemplates(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}): Promise<{ resourceTemplates: ResourceTemplate[] }>;
|
||||
|
||||
// Invocation methods used by paged result panels
|
||||
callTool(
|
||||
tool: Tool,
|
||||
args: Record<string, JsonValue>,
|
||||
generalMetadata?: Record<string, string>,
|
||||
toolSpecificMetadata?: Record<string, string>,
|
||||
generalMetadata?: RequestMetadata,
|
||||
toolSpecificMetadata?: RequestMetadata,
|
||||
): Promise<ToolCallInvocation>;
|
||||
readResource(
|
||||
uri: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<ResourceReadInvocation>;
|
||||
readResourceFromTemplate(
|
||||
uriTemplate: string,
|
||||
params: Record<string, string>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<ResourceTemplateReadInvocation>;
|
||||
getPrompt(
|
||||
name: string,
|
||||
params?: Record<string, string>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<PromptGetInvocation>;
|
||||
|
||||
// Misc surface required by hooks/state
|
||||
|
|
@ -182,6 +183,6 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
argumentName: string,
|
||||
argumentValue: string,
|
||||
context?: Record<string, string>,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<{ values: string[]; total?: number; hasMore?: boolean }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ export {
|
|||
} from "./servers.js";
|
||||
export { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
|
||||
export { createTransportNode } from "./transport.js";
|
||||
export { createProxyFetch, readProxyEnv } from "./proxyFetch.js";
|
||||
|
|
|
|||
231
core/mcp/node/proxyFetch.ts
Normal file
231
core/mcp/node/proxyFetch.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import type {
|
||||
Dispatcher,
|
||||
RequestInit as UndiciRequestInit,
|
||||
Response as UndiciResponse,
|
||||
} from "undici";
|
||||
|
||||
/**
|
||||
* The undici and DOM declarations of `RequestInit` / `BodyInit` describe the
|
||||
* same wire contract; they differ only in *which realm's* `ReadableStream`,
|
||||
* `Blob`, and `Headers` types they name, and TypeScript cannot relate two `lib`
|
||||
* sets' structurally-identical stream types (their `pipeThrough` signatures
|
||||
* differ in variance alone). At runtime there is one implementation — undici
|
||||
* consumes exactly the values Node's own `fetch` does — so these two casts
|
||||
* bridge a declaration gap rather than a real type difference, and they are kept
|
||||
* to this one module boundary instead of leaking into callers.
|
||||
*/
|
||||
function asUndiciInit(init: RequestInit | undefined): UndiciRequestInit {
|
||||
return init as UndiciRequestInit;
|
||||
}
|
||||
|
||||
/** See {@link asUndiciInit} — same declaration gap, opposite direction. */
|
||||
function asBodyInit(body: UndiciResponse["body"]): BodyInit | null {
|
||||
return body as BodyInit | null;
|
||||
}
|
||||
|
||||
/** Standard proxy env vars, in the precedence undici's EnvHttpProxyAgent uses. */
|
||||
const PROXY_ENV_VARS = [
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* First proxy URL found in the environment, or `undefined` if none is set.
|
||||
* Exported for tests and so callers can decide whether proxying is in effect.
|
||||
*/
|
||||
export function readProxyEnv(): string | undefined {
|
||||
for (const name of PROXY_ENV_VARS) {
|
||||
const value = process.env[name];
|
||||
if (value && value.trim() !== "") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses for which the `Response` constructor rejects a non-null body.
|
||||
*
|
||||
* undici already returns a null body for these, so the guard is belt-and-braces
|
||||
* — but a `Response` constructor throwing inside the adapter would surface as an
|
||||
* opaque failure of the request itself, which is a bad trade for one `Set`.
|
||||
*/
|
||||
const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([
|
||||
101, 103, 204, 205, 304,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Re-wraps an undici `Response` as a *global* one, preserving streaming.
|
||||
*
|
||||
* This is not ceremony. Userland undici's `Response` is a different class from
|
||||
* `globalThis.Response`, so `res instanceof Response` is `false` for it — and the
|
||||
* SDK branches on exactly that: `parseErrorResponse` (in
|
||||
* `@modelcontextprotocol/client`) reads `input instanceof Response ? await
|
||||
* input.text() : input`, so an undici response falls into the string branch and
|
||||
* every OAuth error degrades to `Raw body: [object Response]`. Handing back a
|
||||
* genuine global `Response` keeps every consumer on the class it tests for.
|
||||
*
|
||||
* The body passes through as a `ReadableStream` rather than being buffered, so
|
||||
* SSE responses (the streamable-HTTP transport's whole event channel) still
|
||||
* stream.
|
||||
*
|
||||
* `url`, `redirected`, and `type` are restored afterwards with
|
||||
* `defineProperty`, because the `Response` constructor cannot set them and a
|
||||
* rebuilt response would otherwise report `url: ""` and `redirected: false`
|
||||
* even after undici followed a redirect — a fetch-contract regression visible
|
||||
* only to proxy users. `core/auth/endpointOverrides.ts` guards the same three
|
||||
* properties by declining to rebuild at all; this adapter has no such choice,
|
||||
* since the response arrives from the wrong realm, so it reinstates them
|
||||
* instead. They shadow the prototype getters as own properties, which is what
|
||||
* `Response` exposes them as to a consumer.
|
||||
*/
|
||||
function toGlobalResponse(res: UndiciResponse): Response {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of res.headers) headers.append(key, value);
|
||||
const out = new Response(
|
||||
NULL_BODY_STATUSES.has(res.status) ? null : asBodyInit(res.body),
|
||||
{
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
},
|
||||
);
|
||||
for (const [key, value] of [
|
||||
["url", res.url],
|
||||
["redirected", res.redirected],
|
||||
["type", res.type],
|
||||
] as const) {
|
||||
Object.defineProperty(out, key, {
|
||||
value,
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads userland undici and binds its `fetch` to an `EnvHttpProxyAgent`.
|
||||
*
|
||||
* Both halves of that pair must come from the *same* undici. Injecting a
|
||||
* userland dispatcher into Node's built-in `fetch` — what this module used to do
|
||||
* — couples the two undici copies at the dispatcher handler interface, which is
|
||||
* not stable across majors: Node 22 ships undici 6, Node 24 ships 7, Node 26
|
||||
* ships 8, so a userland undici 8 agent handed a built-in undici 7 handler is
|
||||
* rejected with `invalid onRequestStart method` and the request never leaves the
|
||||
* process (#2067). Using undici's own `fetch` keeps the handler on both sides of
|
||||
* the call inside one copy, which is why it works unchanged from the Node 22.19
|
||||
* engine floor through Node 26.
|
||||
*/
|
||||
/**
|
||||
* Process-wide, so every client shares one `EnvHttpProxyAgent` and therefore one
|
||||
* connection pool. The memo is deliberately not per-`createProxyFetch()` call:
|
||||
* the TUI builds an environment per configured server, and a pool each would
|
||||
* multiply sockets against the proxy for no benefit.
|
||||
*/
|
||||
let loadedProxiedFetch: Promise<typeof fetch> | undefined;
|
||||
|
||||
async function loadProxiedFetch(): Promise<typeof fetch> {
|
||||
let undici: typeof import("undici");
|
||||
try {
|
||||
undici = await import("undici");
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
"HTTPS_PROXY / HTTP_PROXY is set but the `undici` package could not be " +
|
||||
"loaded, so requests cannot be routed through the proxy. This is a " +
|
||||
"packaging fault rather than something to fix by installing undici; " +
|
||||
"please report it, or unset the proxy env var to fall back to a direct " +
|
||||
"connection.",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
const dispatcher: Dispatcher = new undici.EnvHttpProxyAgent();
|
||||
return async (input, init) => {
|
||||
const [target, requestInit] = toUndiciRequest(input, init);
|
||||
return toGlobalResponse(
|
||||
await undici.fetch(target, { ...asUndiciInit(requestInit), dispatcher }),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes fetch's `input`/`init` pair into something undici's `fetch` accepts.
|
||||
*
|
||||
* undici builds its own `Request` from `input`, and its constructor cannot
|
||||
* consume a *global* `Request` — it would stringify it to `"[object Request]"`
|
||||
* and request that as a URL. Every call site in this repo and in the SDK passes
|
||||
* a string or `URL` today, so the unpacking branch is defensive; without it a
|
||||
* future caller passing a `Request` would fail in a way that looks like a
|
||||
* network error rather than a type mismatch.
|
||||
*/
|
||||
function toUndiciRequest(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
): [string, RequestInit] {
|
||||
if (input instanceof Request) {
|
||||
return [
|
||||
input.url,
|
||||
{
|
||||
method: input.method,
|
||||
headers: input.headers,
|
||||
// A stream body needs `duplex: "half"`; the field is undici's, not in
|
||||
// the DOM `RequestInit`, so it rides along as an extra property.
|
||||
...(input.body ? { body: input.body, duplex: "half" } : {}),
|
||||
signal: input.signal,
|
||||
// Everything else the Request carries. Dropping these would silently
|
||||
// change fetch semantics under a proxy — most visibly `redirect`, where
|
||||
// a `new Request(url, { redirect: "manual" })` would start following
|
||||
// redirects instead of surfacing them.
|
||||
redirect: input.redirect,
|
||||
cache: input.cache,
|
||||
credentials: input.credentials,
|
||||
integrity: input.integrity,
|
||||
keepalive: input.keepalive,
|
||||
mode: input.mode,
|
||||
referrer: input.referrer,
|
||||
referrerPolicy: input.referrerPolicy,
|
||||
// An explicit `init` still wins, per the fetch contract.
|
||||
...init,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [input instanceof URL ? input.href : input, init ?? {}];
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch that routes through `HTTPS_PROXY` / `HTTP_PROXY`, honoring `NO_PROXY`,
|
||||
* or `undefined` when no proxy env var is set.
|
||||
*
|
||||
* Returning `undefined` rather than an identity wrapper is what keeps non-proxy
|
||||
* users paying nothing: callers assign the result straight into an optional
|
||||
* `fetch` slot, so undici is never imported and the built-in `fetch` is used as
|
||||
* before.
|
||||
*
|
||||
* The env is read once, when this is called, to decide *whether* to proxy at
|
||||
* all. The agent itself is created lazily on the first request, so merely
|
||||
* constructing a client costs nothing — and note that `EnvHttpProxyAgent`
|
||||
* captures the proxy URLs at *its* construction (measured), evaluating only
|
||||
* `NO_PROXY` per request. That is invisible in production, where the
|
||||
* environment does not change mid-process, but it means a test that mutates
|
||||
* `HTTP_PROXY` after any request has already been made is talking to an agent
|
||||
* that is not listening: the proxy suites isolate these variables for a whole
|
||||
* file so nothing can build the process-wide agent before they do.
|
||||
*
|
||||
* This belongs at the *bottom* of a client's fetch stack — the Node clients'
|
||||
* `environment.fetch` — because proxying now means substituting the fetch rather
|
||||
* than decorating one. Wrapping it around a supplied `fetchFn` (as the old
|
||||
* `withProxyDispatcher` did inside `createTransportNode`) would discard that
|
||||
* function's behavior; installing it underneath instead lets `InspectorClient`'s
|
||||
* own wrappers — request tracking, OAuth endpoint overrides — compose over it.
|
||||
* A side benefit: OAuth discovery and token requests go through
|
||||
* `environment.fetch` too, and were never proxied before.
|
||||
*/
|
||||
export function createProxyFetch(): typeof fetch | undefined {
|
||||
if (readProxyEnv() === undefined) return undefined;
|
||||
|
||||
return async (input, init) => {
|
||||
loadedProxiedFetch ??= loadProxiedFetch();
|
||||
return (await loadedProxiedFetch)(input, init);
|
||||
};
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ export function headersToServerSettings(
|
|||
return {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
|
|
|
|||
|
|
@ -16,68 +16,7 @@ import {
|
|||
createAuthChallengeInterceptFetch,
|
||||
createAuthChallengeObserverFetch,
|
||||
} from "./authChallengeFetch.js";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
/** Node's `RequestInit` plus the undici-specific `dispatcher` option. */
|
||||
type NodeRequestInit = RequestInit & { dispatcher?: Dispatcher };
|
||||
|
||||
/** Standard proxy env vars, in the precedence undici's EnvHttpProxyAgent uses. */
|
||||
const PROXY_ENV_VARS = [
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* First proxy URL found in the environment, or `undefined` if none is set.
|
||||
* Exported for tests and so callers can decide whether proxying is in effect.
|
||||
*/
|
||||
export function readProxyEnv(): string | undefined {
|
||||
for (const name of PROXY_ENV_VARS) {
|
||||
const value = process.env[name];
|
||||
if (value && value.trim() !== "") return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a fetch function so outbound HTTP/HTTPS requests honor the standard
|
||||
* `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` environment variables.
|
||||
*
|
||||
* Node's built-in `fetch` (the bundled undici) accepts a `dispatcher` option
|
||||
* in `RequestInit`; undici's `EnvHttpProxyAgent` reads the proxy env vars and
|
||||
* routes per-request, so a single dispatcher covers both schemes and respects
|
||||
* `NO_PROXY`. The `undici` package is loaded lazily on first call so callers
|
||||
* that never set a proxy env var (TUI, web backend) need no extra dependency.
|
||||
* If the proxy is configured but `undici` is unavailable, the wrapper throws
|
||||
* with an actionable message rather than silently ignoring the env var.
|
||||
*/
|
||||
export function withProxyDispatcher(baseFetch: typeof fetch): typeof fetch {
|
||||
if (readProxyEnv() === undefined) return baseFetch;
|
||||
|
||||
let dispatcherPromise: Promise<Dispatcher> | undefined;
|
||||
const getDispatcher = (): Promise<Dispatcher> => {
|
||||
dispatcherPromise ??= import("undici").then(
|
||||
({ EnvHttpProxyAgent }) => new EnvHttpProxyAgent(),
|
||||
(cause: unknown) => {
|
||||
throw new Error(
|
||||
"HTTPS_PROXY / HTTP_PROXY is set but the `undici` package is not " +
|
||||
"available. Install it (it ships with the CLI client) or unset the " +
|
||||
"proxy env var.",
|
||||
{ cause },
|
||||
);
|
||||
},
|
||||
);
|
||||
return dispatcherPromise;
|
||||
};
|
||||
|
||||
const proxiedFetch: typeof fetch = async (input, init) => {
|
||||
const dispatcher = await getDispatcher();
|
||||
return baseFetch(input, { ...init, dispatcher } as NodeRequestInit);
|
||||
};
|
||||
return proxiedFetch;
|
||||
}
|
||||
import { createProxyFetch } from "./proxyFetch.js";
|
||||
|
||||
/**
|
||||
* Build the wire `headers` record from `settings.headers`, dropping rows with
|
||||
|
|
@ -116,9 +55,12 @@ export function createTransportNode(
|
|||
onAuthChallengeObserved,
|
||||
} = options;
|
||||
|
||||
// Proxy-wrap first so the auth-challenge interceptor observes responses from
|
||||
// the (optionally proxied) network call.
|
||||
const baseFetch = withProxyDispatcher(optionsFetchFn ?? globalThis.fetch);
|
||||
// `optionsFetchFn` is the caller's whole fetch stack and already sits on top of
|
||||
// a proxy-aware base when one is needed — the Node clients install
|
||||
// `createProxyFetch()` as `environment.fetch`, and `InspectorClient` wraps
|
||||
// that. The fallback is for the one caller that supplies nothing: the web
|
||||
// backend (`core/mcp/remote/node/server.ts`), which calls this directly.
|
||||
const baseFetch = optionsFetchFn ?? createProxyFetch() ?? globalThis.fetch;
|
||||
// Purely passive, so — unlike proxying and interception — it is safe to
|
||||
// apply to any fetch, including a caller's explicit one.
|
||||
const withChallengeObserver = (inner: typeof fetch): typeof fetch =>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
import {
|
||||
buildOAuthConnectionState,
|
||||
hasPersistedOAuthServerState,
|
||||
isAccessTokenUsable,
|
||||
isAccessTokenProvablyUnexpired,
|
||||
isServerOAuthConfigured,
|
||||
protocolFromOAuthConfig,
|
||||
} from "../auth/connection-state.js";
|
||||
|
|
@ -522,9 +522,12 @@ export class OAuthManager {
|
|||
* satisfied without an authorization-server round-trip.
|
||||
*
|
||||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||||
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
|
||||
* token is already in storage. `invalid_token` and `unauthorized` always
|
||||
* return `false` — the resource server explicitly rejected the credential.
|
||||
* SEP-2350 union. For `token_expired`, returns `true` only when storage holds
|
||||
* a *provably* unexpired token — a JWT whose `exp` is still in the future. An
|
||||
* opaque token carries no local expiry evidence, so the resource server's
|
||||
* verdict stands and re-authorization proceeds. `invalid_token` and
|
||||
* `unauthorized` always return `false` — the resource server explicitly
|
||||
* rejected the credential.
|
||||
*/
|
||||
async checkAuthChallengeSatisfied(
|
||||
challenge: AuthChallenge,
|
||||
|
|
@ -542,7 +545,8 @@ export class OAuthManager {
|
|||
|
||||
if (challenge.reason !== "insufficient_scope") {
|
||||
return (
|
||||
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
|
||||
challenge.reason === "token_expired" &&
|
||||
isAccessTokenProvablyUnexpired(tokens)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ import type { LogEvent } from "pino";
|
|||
import { Hono } from "hono";
|
||||
import type { Context, Env, Next } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import { createTransportNode } from "../../node/transport.js";
|
||||
import { createProxyFetch } from "../../node/proxyFetch.js";
|
||||
import type {
|
||||
RemoteConnectRequest,
|
||||
RemoteSendRequest,
|
||||
|
|
@ -41,6 +43,7 @@ import {
|
|||
} from "../../types.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
RequestMetadata,
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
StdioServerConfig,
|
||||
|
|
@ -254,6 +257,23 @@ export interface RemoteServerOptions {
|
|||
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
|
||||
sandboxUrl?: string;
|
||||
|
||||
/**
|
||||
* Publish a fully-wrapped MCP App document to the backend's dedicated
|
||||
* app-origin listener and return the URL to load it from (#2056). When set,
|
||||
* `POST /api/app-document` is served; when absent — or when it returns
|
||||
* `null`, meaning the listener never bound — the route answers 503 and the
|
||||
* browser falls back to the default opaque-origin `srcdoc` render.
|
||||
*
|
||||
* Supplied by the web backends only. It is an option rather than a route
|
||||
* built here because the listener is a `clients/web/server` concern; this
|
||||
* module owns nothing but the authenticated seam the browser reaches it
|
||||
* through.
|
||||
*/
|
||||
publishAppDocument?: (doc: {
|
||||
html: string;
|
||||
csp?: string;
|
||||
}) => { url: string } | null;
|
||||
|
||||
/** Initial config for GET /api/config. Caller must pass this (e.g. from webServerConfigToInitialPayload(config)). */
|
||||
initialConfig: InitialConfigPayload;
|
||||
|
||||
|
|
@ -278,6 +298,50 @@ export interface RemoteServerOptions {
|
|||
secretStorageResolver?: () => Promise<SecretStorageInfo | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on a single MCP App document accepted by `POST /api/app-document`
|
||||
* (#2056). Counted in UTF-16 code units, which is what `String.length` gives —
|
||||
* a loose but cheap proxy for bytes, and it only has to keep a runaway or
|
||||
* hostile payload from being pinned in the backend's memory. Generous: a real
|
||||
* app inlines its own scripts and styles into this one document.
|
||||
*/
|
||||
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upper bound on the request BODY, in bytes, enforced by `bodyLimit` *before*
|
||||
* anything is parsed. {@link MAX_APP_DOCUMENT_CHARS} alone is not enough: it is
|
||||
* checked after `c.req.json()` has already buffered and parsed the whole
|
||||
* request, so an unbounded body — or one whose bulk sits in `csp` or in keys
|
||||
* the route never reads — is fully materialized in memory before being
|
||||
* rejected. Generous relative to the document bound because JSON escaping
|
||||
* inflates the payload (`<` stays one byte, but a `"` or a newline becomes
|
||||
* two, and non-BMP text more), so this must not reject a document the char
|
||||
* check would accept.
|
||||
*/
|
||||
const MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Upper bound on the per-app CSP policy string. Unlike the document, this is
|
||||
* retained for the life of the published entry, and a real policy is a few
|
||||
* hundred characters — an app pushing more is not describing sources.
|
||||
*/
|
||||
const MAX_APP_CSP_CHARS = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Whether a string is safe to emit as an HTTP header VALUE.
|
||||
*
|
||||
* The published `csp` is handed to `res.writeHead()` verbatim by the app-origin
|
||||
* controller. Node validates header values there and throws
|
||||
* `ERR_INVALID_CHAR` **synchronously inside the request handler** — outside
|
||||
* this route's error handling, on a request this route is not even part of —
|
||||
* so a CR/LF or NUL accepted here surfaces later as a thrown listener rather
|
||||
* than a 400. Restrict to tab plus printable ASCII, which is stricter than RFC
|
||||
* 9110 field-value grammar and is all a CSP policy needs.
|
||||
*/
|
||||
function isHeaderSafeValue(value: string): boolean {
|
||||
return !/[^\t\x20-\x7e]/.test(value);
|
||||
}
|
||||
|
||||
export interface CreateRemoteAppResult {
|
||||
/** The Hono app */
|
||||
app: Hono;
|
||||
|
|
@ -705,6 +769,73 @@ export function createRemoteApp(
|
|||
return c.json(payload);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hand the backend a wrapped MCP App document and get back the URL its
|
||||
* dedicated origin serves it from (#2056).
|
||||
*
|
||||
* The browser is what holds the document — it read the `ui://` resource over
|
||||
* the MCP connection and wrapped it — so the only way it can reach a real
|
||||
* HTTP origin is to hand the bytes back. This route is the authenticated
|
||||
* seam for that; the returned URL is unguessable and is served by a separate
|
||||
* listener on its own port, which is what makes the app's origin real
|
||||
* instead of opaque.
|
||||
*/
|
||||
app.post(
|
||||
"/api/app-document",
|
||||
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
|
||||
bodyLimit({
|
||||
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
|
||||
onError: (c) => c.json({ error: "Document too large" }, 413),
|
||||
}),
|
||||
async (c) => {
|
||||
const publish = options.publishAppDocument;
|
||||
if (!publish) {
|
||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
// `null` and `[1,2]` are both valid JSON, so `c.req.json()` resolving is
|
||||
// not proof there is an object to destructure — and destructuring `null`
|
||||
// here would throw OUTSIDE the try above, turning a malformed body into a
|
||||
// 500 instead of the 400 every other bad shape gets.
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
return c.json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
const { html, csp } = parsed as { html?: unknown; csp?: unknown };
|
||||
if (typeof html !== "string" || html === "") {
|
||||
return c.json({ error: "Missing html" }, 400);
|
||||
}
|
||||
if (csp !== undefined && typeof csp !== "string") {
|
||||
return c.json({ error: "csp must be a string" }, 400);
|
||||
}
|
||||
if (csp !== undefined && !isHeaderSafeValue(csp)) {
|
||||
// Rejected here rather than sanitized: a policy carrying a control
|
||||
// character is not a policy with a stray byte in it, it is a caller
|
||||
// doing something this route has no honest interpretation of.
|
||||
return c.json({ error: "csp is not a valid header value" }, 400);
|
||||
}
|
||||
if (html.length > MAX_APP_DOCUMENT_CHARS) {
|
||||
return c.json({ error: "Document too large" }, 413);
|
||||
}
|
||||
if (csp !== undefined && csp.length > MAX_APP_CSP_CHARS) {
|
||||
return c.json({ error: "csp too large" }, 413);
|
||||
}
|
||||
const published = publish({ html, csp });
|
||||
if (!published) {
|
||||
return c.json({ error: "App-origin hosting is not available" }, 503);
|
||||
}
|
||||
return c.json(published);
|
||||
},
|
||||
);
|
||||
|
||||
app.post("/api/mcp/connect", async (c) => {
|
||||
let body: RemoteConnectRequest;
|
||||
try {
|
||||
|
|
@ -1054,7 +1185,14 @@ export function createRemoteApp(
|
|||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
// Proxy-aware, not the bare global. This route is the browser's ONLY way
|
||||
// out to the network: the web client's `environment.fetch` is
|
||||
// `createRemoteFetch()`, which forwards OAuth discovery and token
|
||||
// requests here. Left on the global `fetch`, a corporate-proxy user could
|
||||
// connect to a server but never authorize against it — and Node's native
|
||||
// `NODE_USE_ENV_PROXY` does not cover them either, being unsupported at
|
||||
// our 22.19 engine floor (#2067).
|
||||
const res = await (createProxyFetch() ?? fetch)(url, {
|
||||
method,
|
||||
headers: new Headers(headers),
|
||||
body: reqBody,
|
||||
|
|
@ -1212,6 +1350,20 @@ export function createRemoteApp(
|
|||
// credentials are intentionally lost on first read (hard cutover per
|
||||
// #1358 decision 4). Users re-enter via the form or hand-edit into the
|
||||
// flat shape.
|
||||
// The *container* `_meta` requires — a plain JSON object.
|
||||
//
|
||||
// Value-level validity is deliberately not checked here. Values are
|
||||
// arbitrary JSON (#1910), and the one class that survives `JSON.parse` yet
|
||||
// cannot be sent — a non-finite number, from a literal like `1e400` in a
|
||||
// hand-edited catalog — is filtered **per key** by
|
||||
// `normalizeStoredMetadata`, which every read routes through. Rejecting the
|
||||
// whole field here instead would cost a user every other key they had
|
||||
// configured, for one bad value.
|
||||
//
|
||||
// Note the wire cannot carry the bad case at all: a client's own
|
||||
// `JSON.stringify` turns `Infinity` into `null` before the request is sent.
|
||||
const isJsonObject = (v: unknown): v is RequestMetadata =>
|
||||
v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const isStringRecord = (v: unknown): v is Record<string, string> => {
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
||||
for (const val of Object.values(v as Record<string, unknown>)) {
|
||||
|
|
@ -1324,10 +1476,18 @@ export function createRemoteApp(
|
|||
);
|
||||
delete valObj.headers;
|
||||
}
|
||||
if ("metadata" in valObj && !isKvArray(valObj.metadata)) {
|
||||
// A JSON object post-#1910; the pre-#1910 `{ key, value }[]` pair array
|
||||
// is still accepted so an existing file keeps working (it is normalized
|
||||
// by `normalizeStoredMetadata` on the way into the settings shape, and
|
||||
// rewritten as an object on the next save).
|
||||
if (
|
||||
"metadata" in valObj &&
|
||||
!isJsonObject(valObj.metadata) &&
|
||||
!isKvArray(valObj.metadata)
|
||||
) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "metadata" },
|
||||
"Dropping malformed `metadata` field — expected `Array<{ key: string, value: string }>`.",
|
||||
"Dropping malformed `metadata` field — expected a JSON object.",
|
||||
);
|
||||
delete valObj.metadata;
|
||||
}
|
||||
|
|
@ -1556,10 +1716,14 @@ export function createRemoteApp(
|
|||
error: "settings.headers must be an array of { key, value }",
|
||||
};
|
||||
}
|
||||
if (!isKvArray(obj.metadata)) {
|
||||
// `_meta` takes any JSON, so metadata crosses the wire as a plain JSON
|
||||
// object rather than the `{ key, value }` rows headers/env still use
|
||||
// (#1910). Only the container is checked — the values are arbitrary JSON
|
||||
// by design, and anything that survived `c.req.json()` already is.
|
||||
if (!isJsonObject(obj.metadata)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.metadata must be an array of { key, value }",
|
||||
error: "settings.metadata must be a JSON object",
|
||||
};
|
||||
}
|
||||
// env is optional on the wire (older clients / non-stdio servers won't send
|
||||
|
|
@ -1735,7 +1899,7 @@ export function createRemoteApp(
|
|||
// Absent → empty list, matching the read side. The write-through drops
|
||||
// empty-key rows and clears `config.env` when the list is empty.
|
||||
env: isKvArray(obj.env) ? obj.env : [],
|
||||
metadata: obj.metadata as { key: string; value: string }[],
|
||||
metadata: obj.metadata as RequestMetadata,
|
||||
connectionTimeout: obj.connectionTimeout as number,
|
||||
requestTimeout: obj.requestTimeout as number,
|
||||
// Absent → product default, matching the read side
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import type { Root } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
RequestMetadata,
|
||||
MCPConfig,
|
||||
MCPServerConfig,
|
||||
ServerEntry,
|
||||
|
|
@ -28,6 +29,8 @@ import {
|
|||
envSecretField,
|
||||
} from "../auth/secret-fields.js";
|
||||
import { toRecord } from "../json/jsonUtils.js";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import { isSerializableJson } 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.
|
||||
|
|
@ -194,6 +197,130 @@ export function envPairsToRecord(
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a server's `metadata` off disk into the in-memory `RequestMetadata`
|
||||
* object, accepting both the current object shape and the pre-#1910
|
||||
* `{ key, value }[]` pair array.
|
||||
*
|
||||
* The pair array is the shape every `mcp.json` written before #1910 carries, so
|
||||
* dropping it would silently stop sending a user's configured `_meta` on the
|
||||
* first read of an existing file. It is read here and never written back:
|
||||
* `inspectorSettingsToStoredFields` emits only the object form, so the file is
|
||||
* migrated the next time the entry is saved.
|
||||
*
|
||||
* Anything else — a string, a number, an array of non-pairs — is not metadata
|
||||
* the Inspector can send, so it is dropped with a warning rather than half-
|
||||
* interpreted, following `cleanRoots` / `cleanAuthorizationParams` above. A
|
||||
* pair whose key is blank is skipped for the same reason the write side omits
|
||||
* it: `_meta` has no meaningful empty key.
|
||||
*/
|
||||
export function normalizeStoredMetadata(
|
||||
metadata: StoredMCPServer["metadata"] | unknown,
|
||||
): RequestMetadata {
|
||||
if (metadata === undefined || metadata === null) return {};
|
||||
|
||||
if (Array.isArray(metadata)) {
|
||||
const out: RequestMetadata = {};
|
||||
for (const entry of metadata) {
|
||||
if (
|
||||
typeof entry !== "object" ||
|
||||
entry === null ||
|
||||
typeof (entry as { key?: unknown }).key !== "string"
|
||||
) {
|
||||
// Structural message only — never the entry. A malformed legacy row
|
||||
// still holds whatever the user put in it, which for `_meta` can be a
|
||||
// credential, and this goes to the console of every client that reads
|
||||
// the catalog.
|
||||
console.warn(
|
||||
"Ignoring malformed legacy metadata entry (expected `{ key, value }`).",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { key, value } = entry as { key: string; value?: JsonValue };
|
||||
if (key.trim() === "") continue;
|
||||
// `??`, not `||`: `null` is a legal `_meta` value and must survive the
|
||||
// migration as `null`. Only a genuinely absent `value` becomes `""`.
|
||||
const resolved = value === undefined ? "" : value;
|
||||
if (!keepSerializable(key, resolved)) continue;
|
||||
defineMetadataKey(out, key, resolved);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (typeof metadata === "object") {
|
||||
// A hand-edited catalog reaches this untouched by any editor, so it is a
|
||||
// real source of values that parse but cannot be sent — `1e400` becomes
|
||||
// `Infinity`, which the next serialization writes as `null`. Same
|
||||
// invariant the web editor and the CLI enforce, applied per key so one bad
|
||||
// value does not cost the rest.
|
||||
const out: RequestMetadata = {};
|
||||
for (const [key, value] of Object.entries(metadata as RequestMetadata)) {
|
||||
if (keepSerializable(key, value)) defineMetadataKey(out, key, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Ignoring malformed \`metadata\` (expected a JSON object, got ${describeJsonType(metadata)}).`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a metadata key as an **own** property.
|
||||
*
|
||||
* `out[key] = value` invokes the prototype setter for the key `"__proto__"`,
|
||||
* so the entry vanishes on reload and — for an object value — the returned
|
||||
* metadata is given a caller-controlled prototype. `_meta` keys come from a
|
||||
* file a user (or something that wrote that file) controls, so `"__proto__"`
|
||||
* is a key that can genuinely arrive.
|
||||
*
|
||||
* Both branches of `normalizeStoredMetadata` go through this rather than each
|
||||
* spelling it out. The object branch was originally a spread — safe, because
|
||||
* spread defines rather than sets — and rewriting it into a per-key loop for
|
||||
* value filtering silently reintroduced the hazard the legacy branch had
|
||||
* already fixed. One mechanism is what stops that recurring.
|
||||
*/
|
||||
function defineMetadataKey(
|
||||
out: RequestMetadata,
|
||||
key: string,
|
||||
value: JsonValue,
|
||||
): void {
|
||||
Object.defineProperty(out, key, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a metadata value can be sent as written, warning by key when it
|
||||
* cannot.
|
||||
*
|
||||
* Names the key and the offending type, never the value: `_meta` carries
|
||||
* whatever the user configured, credentials included, and these warnings reach
|
||||
* ordinary console output.
|
||||
*/
|
||||
function keepSerializable(key: string, value: unknown): boolean {
|
||||
if (isSerializableJson(value)) return true;
|
||||
console.warn(
|
||||
`Ignoring metadata key "${key}" — its value is not JSON that can be sent ` +
|
||||
`(got ${describeJsonType(value)}; note \`1e400\` parses to Infinity).`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A one-word type name for a diagnostic that must not disclose the value. */
|
||||
function describeJsonType(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "array";
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
return Number.isNaN(value) ? "NaN" : "a non-finite number";
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a server's `oauth.authorizationParams` as it comes off disk, or
|
||||
* `undefined` when there is nothing usable. (#2018)
|
||||
|
|
@ -349,7 +476,7 @@ export function storedFieldsToInspectorSettings(
|
|||
const settings: InspectorServerSettings = {
|
||||
headers: headersPairs,
|
||||
env: envRecordToPairs(stored.env),
|
||||
metadata: stored.metadata ?? [],
|
||||
metadata: normalizeStoredMetadata(stored.metadata),
|
||||
connectionTimeout: stored.connectionTimeout ?? 0,
|
||||
requestTimeout: stored.requestTimeout ?? 0,
|
||||
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
||||
|
|
@ -458,9 +585,11 @@ export function inspectorSettingsToStoredFields(
|
|||
out.headers = headersRecord;
|
||||
}
|
||||
|
||||
const metadataFiltered = settings.metadata.filter((m) => m.key.trim() !== "");
|
||||
if (metadataFiltered.length > 0) {
|
||||
out.metadata = metadataFiltered;
|
||||
// Blank-key entries can't exist in the object form (an object has no blank
|
||||
// key the user can leave mid-edit, the way a `{key,value}` row could), so
|
||||
// there is nothing to filter — only the empty-object case to omit.
|
||||
if (Object.keys(settings.metadata).length > 0) {
|
||||
out.metadata = settings.metadata;
|
||||
}
|
||||
|
||||
if (settings.connectionTimeout > 0) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import { isClientDecodeRejection } from "../listSalvage.js";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
||||
/**
|
||||
|
|
@ -67,7 +68,7 @@ export interface ManagedListConfig<T, M extends ManagedListEventMap> {
|
|||
fetchAll: (
|
||||
client: InspectorClientProtocol,
|
||||
cacheMode: CacheMode | undefined,
|
||||
metadata: Record<string, string> | undefined,
|
||||
metadata: RequestMetadata | undefined,
|
||||
) => Promise<T[]>;
|
||||
/**
|
||||
* Whether this list drives a list-changed indicator. When true, a
|
||||
|
|
@ -97,7 +98,7 @@ export abstract class ManagedListState<
|
|||
protected items: T[] = [];
|
||||
protected client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private _metadata: Record<string, string> | undefined = undefined;
|
||||
private _metadata: RequestMetadata | undefined = undefined;
|
||||
private listChanged = false;
|
||||
// The last fetch's failure, kept as observable state so a load that fails
|
||||
// (a transport error, or a result the SDK codec rejects as invalid) is
|
||||
|
|
@ -282,7 +283,7 @@ export abstract class ManagedListState<
|
|||
this.emit("errorChange", value);
|
||||
}
|
||||
|
||||
setMetadata(metadata?: Record<string, string>): void {
|
||||
setMetadata(metadata?: RequestMetadata): void {
|
||||
this._metadata = metadata;
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +300,7 @@ export abstract class ManagedListState<
|
|||
* the `list_changed` auto-refresh) catch it explicitly (#1953).
|
||||
*/
|
||||
async refresh(
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
cacheMode?: CacheMode,
|
||||
): Promise<T[]> {
|
||||
let next: T[] | null;
|
||||
|
|
@ -336,7 +337,7 @@ export abstract class ManagedListState<
|
|||
* the console; empty list is the right semantics).
|
||||
*/
|
||||
private async fetchItems(
|
||||
metadata: Record<string, string> | undefined,
|
||||
metadata: RequestMetadata | undefined,
|
||||
cacheMode: CacheMode | undefined,
|
||||
): Promise<T[] | null> {
|
||||
const client = this.client;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
import type { PagePaginationState } from "./pagedToolsState.js";
|
||||
|
||||
|
|
@ -112,7 +113,7 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
|
||||
async loadPage(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> {
|
||||
const c = this.client;
|
||||
if (!c || c.getStatus() !== "connected") {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
||||
export interface PagedResourceTemplatesStateEventMap {
|
||||
|
|
@ -59,7 +60,7 @@ export class PagedResourceTemplatesState extends TypedEventTarget<PagedResourceT
|
|||
|
||||
async loadPage(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> {
|
||||
const c = this.client;
|
||||
if (!c || c.getStatus() !== "connected") {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import type { RequestMetadata } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
import type { PagePaginationState } from "./pagedToolsState.js";
|
||||
|
||||
|
|
@ -113,7 +114,7 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
|
||||
async loadPage(
|
||||
cursor?: string,
|
||||
metadata?: Record<string, string>,
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<LoadPageResult> {
|
||||
const c = this.client;
|
||||
if (!c || c.getStatus() !== "connected") {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/client";
|
|||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
import type { InspectorLogger } from "../logging/logger.js";
|
||||
import type { AppElicitationRenderer } from "./appElicitation.js";
|
||||
import type { JsonValue } from "../json/jsonUtils.js";
|
||||
import type {
|
||||
JsonValue,
|
||||
StrictJsonObject,
|
||||
StrictJsonValue,
|
||||
} from "../json/jsonUtils.js";
|
||||
import type {
|
||||
ClientConfig,
|
||||
EnterpriseManagedAuthIdpConfig,
|
||||
|
|
@ -100,11 +104,20 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Default `_meta` keys merged into every outgoing MCP request. Inspector-
|
||||
* specific (no analog in the broader mcp.json ecosystem), so the pair-array
|
||||
* shape is preserved on disk and in memory.
|
||||
* Default `_meta` payload merged into every outgoing MCP request. Inspector-
|
||||
* specific (no analog in the broader mcp.json ecosystem).
|
||||
*
|
||||
* A JSON object, so a value may be any JSON — object, array, number, boolean,
|
||||
* `null` — not just a string (#1910). Nothing in the MCP spec restricts
|
||||
* `_meta` to string values, and the SDK models it as a passthrough object;
|
||||
* the string-pair restriction was the Inspector's own.
|
||||
*
|
||||
* The pre-#1910 on-disk shape was a `{ key, value }[]` pair array. It is
|
||||
* still **read** (see `normalizeStoredMetadata` in `serverList.ts`) so an
|
||||
* existing `mcp.json` keeps working, but it is never written back — a
|
||||
* round-trip through the Inspector rewrites the field as an object.
|
||||
*/
|
||||
metadata?: { key: string; value: string }[];
|
||||
metadata?: StrictJsonObject;
|
||||
/**
|
||||
* Protocol era to negotiate with this server (`"legacy" | "auto" | "modern"`),
|
||||
* orthogonal to the transport `type`. Inspector-specific (no analog in the
|
||||
|
|
@ -362,6 +375,24 @@ export interface ServerState {
|
|||
stderrLogs: StderrLogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A `_meta` payload: a JSON object whose values may be any JSON, not just
|
||||
* strings (#1910).
|
||||
*
|
||||
* {@link StrictJsonValue}, not `JsonValue`: the latter admits `undefined`,
|
||||
* which `JSON.stringify` drops from an object and turns into `null` inside an
|
||||
* array — so a value the type accepted would not be the value that reaches the
|
||||
* server, contradicting the one promise this payload makes.
|
||||
*
|
||||
* The MCP spec places no restriction on `_meta` value types and the SDK models
|
||||
* the field as a passthrough object, so the Inspector must be able to send —
|
||||
* and to record — nested objects, arrays, numbers, booleans and `null`. Used
|
||||
* for both the per-server default payload (`InspectorServerSettings.metadata`,
|
||||
* `InspectorClientOptions.defaultMetadata`) and the per-call metadata every
|
||||
* request verb accepts.
|
||||
*/
|
||||
export type RequestMetadata = Record<string, StrictJsonValue>;
|
||||
|
||||
/**
|
||||
* Represents a complete resource read invocation, including request parameters,
|
||||
* response, and metadata.
|
||||
|
|
@ -370,7 +401,7 @@ export interface ResourceReadInvocation {
|
|||
result: ReadResourceResult;
|
||||
timestamp: Date;
|
||||
uri: string;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -383,7 +414,7 @@ export interface ResourceTemplateReadInvocation {
|
|||
result: ReadResourceResult;
|
||||
timestamp: Date;
|
||||
params: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -395,7 +426,7 @@ export interface PromptGetInvocation {
|
|||
timestamp: Date;
|
||||
name: string;
|
||||
params?: Record<string, string>;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -409,7 +440,7 @@ export interface ToolCallInvocation {
|
|||
timestamp: Date;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, string>;
|
||||
metadata?: RequestMetadata;
|
||||
/**
|
||||
* Set only on the `skipOutputValidation` path: present when the (delivered)
|
||||
* result's structuredContent does NOT match the tool's declared outputSchema.
|
||||
|
|
@ -690,7 +721,12 @@ export function eraToVersionNegotiation(
|
|||
*/
|
||||
export interface InspectorServerSettings {
|
||||
headers: { key: string; value: string }[];
|
||||
metadata: { key: string; value: string }[];
|
||||
/**
|
||||
* Default `_meta` payload for this server, edited as a JSON object rather
|
||||
* than key/value rows so a value can be any JSON (#1910). Always present;
|
||||
* `{}` means "send no default metadata".
|
||||
*/
|
||||
metadata: RequestMetadata;
|
||||
/**
|
||||
* Environment variables for stdio servers, edited as controlled key/value
|
||||
* rows (mirrors `headers`). Only meaningful for stdio transports; non-stdio
|
||||
|
|
@ -929,7 +965,12 @@ export interface InspectorClientEnvironment {
|
|||
/**
|
||||
* Optional fetch function for HTTP requests (OAuth discovery/token exchange and
|
||||
* MCP transport). When provided, used for both auth and transport to bypass CORS.
|
||||
* - Node: undefined (uses global fetch)
|
||||
* - Node: `createProxyFetch()` (core/mcp/node/proxyFetch.ts) — a proxy-aware
|
||||
* fetch when `HTTPS_PROXY`/`HTTP_PROXY` is set, and `undefined` otherwise,
|
||||
* which leaves the built-in global fetch in place. This is the BOTTOM of the
|
||||
* fetch stack: `InspectorClient` wraps whatever it finds here, so proxying
|
||||
* composes with request tracking and the OAuth endpoint overrides instead of
|
||||
* being discarded by them (#2067).
|
||||
* - Browser: createRemoteFetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
|
|
@ -1085,7 +1126,7 @@ export interface InspectorClientOptions {
|
|||
* site metadata wins on key collision. Set this from `InspectorServerSettings.metadata`
|
||||
* so persisted server-wide metadata reaches the wire on the first request.
|
||||
*/
|
||||
defaultMetadata?: Record<string, string>;
|
||||
defaultMetadata?: RequestMetadata;
|
||||
|
||||
/**
|
||||
* Optional per-server runtime settings forwarded to the transport factory
|
||||
|
|
|
|||
Loading…
Reference in a new issue