@visual-z/xeno 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/** Standard MCP adapters reuse the existing integration owner; no business request is automatically replayed. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { Client, StreamableHTTPClientTransport, UnauthorizedError, InsufficientScopeError, SdkError, SdkHttpError, SdkErrorCode, extractWWWAuthenticateParams,
|
|
5
|
+
type Tool, type Transport, type FetchLike, type ContentBlock, type JsonSchemaType } from "@modelcontextprotocol/client";
|
|
6
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/client/stdio";
|
|
7
|
+
import { parseMcpServers, type McpServerConfig, type McpServers } from "../core/mcp-config.ts";
|
|
8
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
9
|
+
import type { IntegrationFailureCode } from "../core/integration-health.ts";
|
|
10
|
+
import { FileMcpCredentialsStore, type McpCredentialsStore } from "../store/mcp-credentials.ts";
|
|
11
|
+
import type { ExtensionIntegration, IntegrationConnection, IntegrationFailure } from "./integrations.ts";
|
|
12
|
+
import type { ExtensionToolResult } from "./extensions.ts";
|
|
13
|
+
import { McpAuthRequired, McpOAuthProvider } from "./mcp-oauth.ts";
|
|
14
|
+
import { MAX_WEB_RESPONSE_BYTES, type WebFetch } from "./web-http.ts";
|
|
15
|
+
import { McpSchemaError, McpSchemas } from "./mcp-schema.ts";
|
|
16
|
+
import { ToolFault } from "../core/tool-failure.ts";
|
|
17
|
+
|
|
18
|
+
export interface McpServerStatus { name: string; transport: "stdio" | "http"; state: "ready" | "disabled" | IntegrationFailureCode; tools: number; diagnostic?: string }
|
|
19
|
+
export interface McpManagerOptions { cwd: string; credentialsDirectory: string; credentialsStore?: McpCredentialsStore;
|
|
20
|
+
env?: Readonly<Record<string, string | undefined>>; fetch?: WebFetch }
|
|
21
|
+
class McpFailure extends Error {
|
|
22
|
+
readonly code: IntegrationFailureCode;
|
|
23
|
+
constructor(code: IntegrationFailureCode) { super(`MCP ${code}`); this.name = "McpFailure"; this.code = code; }
|
|
24
|
+
}
|
|
25
|
+
function fault(error: unknown): IntegrationFailure {
|
|
26
|
+
const code = error instanceof McpFailure ? error.code : error instanceof McpAuthRequired || error instanceof UnauthorizedError || error instanceof InsufficientScopeError ? "auth_required"
|
|
27
|
+
: error instanceof SdkHttpError ? error.status === 401 || error.status === 403 ? "auth_required" : error.status === 429 ? "rate_limited" : error.status >= 500 ? "offline" : "protocol_error"
|
|
28
|
+
: error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout ? "timeout" : "protocol_error";
|
|
29
|
+
return { kind: "integration_failure", code };
|
|
30
|
+
}
|
|
31
|
+
export function mcpToolName(server: string, name: string, kind = "tool"): string {
|
|
32
|
+
const digest = createHash("sha256").update(JSON.stringify([server, kind, name])).digest("hex").slice(0, 12);
|
|
33
|
+
return `mcp_${server.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 12)}_${name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 20)}_${digest}`;
|
|
34
|
+
}
|
|
35
|
+
function integrationName(name: string) { return `mcp_${name.slice(0, 48)}_${createHash("sha256").update(name).digest("hex").slice(0, 8)}`; }
|
|
36
|
+
function immutable<T>(value: T): T {
|
|
37
|
+
if (value && typeof value === "object") { for (const entry of Object.values(value)) immutable(entry); Object.freeze(value); }
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
function contentResult(content: readonly ContentBlock[], details: unknown, isError = false): ExtensionToolResult {
|
|
41
|
+
const mapped: ExtensionToolResult["content"] = [];
|
|
42
|
+
for (const item of content) {
|
|
43
|
+
if (item.type === "text") mapped.push({ type: "text", text: item.text });
|
|
44
|
+
else if (item.type === "image") mapped.push({ type: "image", data: item.data, mimeType: item.mimeType });
|
|
45
|
+
else if (item.type === "resource" && "text" in item.resource) mapped.push({ type: "text", text: `Resource URI: ${item.resource.uri}\n${item.resource.text}` });
|
|
46
|
+
else if (item.type === "resource_link") mapped.push({ type: "text", text: `Resource: ${item.name}\nURI: ${item.uri}${item.description ? `\n${item.description}` : ""}` });
|
|
47
|
+
else mapped.push({ type: "text", text: `MCP ${item.type} content retained in the structured result; direct presentation unavailable.` });
|
|
48
|
+
}
|
|
49
|
+
return { content: mapped, ...(isJsonValue(details) ? { details } : {}), ...(isError ? { isError: true } : {}) };
|
|
50
|
+
}
|
|
51
|
+
interface Opened { server: string; client: Client; transport: Transport; tools: readonly Tool[]; schemas: McpSchemas; catalog: string; dirty: boolean; close(): Promise<void> }
|
|
52
|
+
/** A single manager belongs to one app/session generation. Disposing never starts a replacement connection. */
|
|
53
|
+
export class McpManager {
|
|
54
|
+
private readonly servers: McpServers;
|
|
55
|
+
private readonly options: McpManagerOptions;
|
|
56
|
+
private readonly store: McpCredentialsStore;
|
|
57
|
+
private readonly lifetime = new AbortController();
|
|
58
|
+
private readonly opened = new Set<Opened>();
|
|
59
|
+
private readonly oauth = new Map<string, McpOAuthProvider>();
|
|
60
|
+
private readonly statuses = new Map<string, McpServerStatus>();
|
|
61
|
+
private readonly challenges = new Map<string, { resourceMetadataUrl?: URL; scope?: string }>();
|
|
62
|
+
private discovered = false;
|
|
63
|
+
private disposal: Promise<void> | undefined;
|
|
64
|
+
constructor(servers: McpServers, options: McpManagerOptions) {
|
|
65
|
+
this.servers = parseMcpServers(servers); this.options = options;
|
|
66
|
+
this.store = options.credentialsStore ?? new FileMcpCredentialsStore(options.credentialsDirectory);
|
|
67
|
+
}
|
|
68
|
+
private async provider(name: string, config: Extract<McpServerConfig, { transport: "http" }>) {
|
|
69
|
+
let provider = this.oauth.get(name);
|
|
70
|
+
if (!provider) {
|
|
71
|
+
provider = await McpOAuthProvider.create(config.url, typeof config.oauth === "object" ? config.oauth : {}, this.store, this.options.env ?? process.env);
|
|
72
|
+
this.oauth.set(name, provider);
|
|
73
|
+
}
|
|
74
|
+
const challenge = this.challenges.get(name);
|
|
75
|
+
if (challenge) provider.setChallenge(challenge.resourceMetadataUrl, challenge.scope);
|
|
76
|
+
return provider;
|
|
77
|
+
}
|
|
78
|
+
private fetch(signal: AbortSignal, timeoutMs: number): FetchLike {
|
|
79
|
+
return async (input, init) => {
|
|
80
|
+
// Protocol session retirement has its own bounded finalizer after business cancellation.
|
|
81
|
+
// DELETE cannot start a business operation and must survive the owner's aborted request signal.
|
|
82
|
+
const cleanup = init?.method === "DELETE", combined = cleanup ? new AbortController().signal
|
|
83
|
+
: AbortSignal.any([signal, this.lifetime.signal, ...(init?.signal ? [init.signal] : [])]), controller = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => controller.abort(), cleanup ? Math.min(timeoutMs, 5_000) : timeoutMs);
|
|
85
|
+
try {
|
|
86
|
+
const response = await (this.options.fetch ?? fetch)(input, { ...init, signal: AbortSignal.any([combined, controller.signal]), redirect: "manual" });
|
|
87
|
+
// Buffer JSON replies with a streamed cap. SSE remains a stream, also counted before parsing any event.
|
|
88
|
+
if (!response.body) { clearTimeout(timer); return response; }
|
|
89
|
+
const reader = response.body.getReader(); let seen = 0;
|
|
90
|
+
const body = new ReadableStream<Uint8Array>({
|
|
91
|
+
async pull(output) {
|
|
92
|
+
try {
|
|
93
|
+
const next = await reader.read();
|
|
94
|
+
if (next.done) { clearTimeout(timer); reader.releaseLock(); output.close(); return; }
|
|
95
|
+
seen += next.value.byteLength;
|
|
96
|
+
if (seen > MAX_WEB_RESPONSE_BYTES) { clearTimeout(timer); await reader.cancel(); output.error(new McpFailure("protocol_error")); return; }
|
|
97
|
+
output.enqueue(next.value);
|
|
98
|
+
} catch { clearTimeout(timer); output.error(new McpFailure(controller.signal.aborted ? "timeout" : "offline")); }
|
|
99
|
+
}, async cancel() { clearTimeout(timer); await reader.cancel(); }
|
|
100
|
+
});
|
|
101
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
102
|
+
} catch (error) { clearTimeout(timer); if (error instanceof McpAuthRequired) throw error; throw new McpFailure(controller.signal.aborted ? "timeout" : "offline"); }
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
private async open(name: string, config: McpServerConfig, signal: AbortSignal): Promise<Opened> {
|
|
106
|
+
signal.throwIfAborted(); this.lifetime.signal.throwIfAborted();
|
|
107
|
+
const timeout = config.timeoutMs ?? 60_000;
|
|
108
|
+
const schemas = new McpSchemas();
|
|
109
|
+
const client = new Client({ name: "xeno", version: "0.5.0" }, { capabilities: { roots: { listChanged: false } },
|
|
110
|
+
versionNegotiation: { mode: "auto" }, inputRequired: { autoFulfill: false }, listMaxPages: 64, jsonSchemaValidator: schemas });
|
|
111
|
+
client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(this.options.cwd).href, name: "workspace" }] }));
|
|
112
|
+
// Remote log notifications and stderr are deliberately not copied into logs: they can contain server secrets.
|
|
113
|
+
client.onerror = () => {};
|
|
114
|
+
const combined = AbortSignal.any([signal, this.lifetime.signal]);
|
|
115
|
+
let closingTransport = false;
|
|
116
|
+
let transport: Transport;
|
|
117
|
+
if (config.transport === "stdio") {
|
|
118
|
+
const env: Record<string, string> = { ...getDefaultEnvironment() };
|
|
119
|
+
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match: string, variable: string) => {
|
|
120
|
+
const found = (this.options.env ?? process.env)[variable]; if (found === undefined) throw new McpFailure("auth_required"); return found;
|
|
121
|
+
});
|
|
122
|
+
transport = new StdioClientTransport({ command: config.command, args: config.args ?? [], env, cwd: this.options.cwd, stderr: "ignore", maxBufferSize: MAX_WEB_RESPONSE_BYTES });
|
|
123
|
+
} else {
|
|
124
|
+
const headers = new Headers(config.headers), env = this.options.env ?? process.env, baseFetch = this.fetch(combined, timeout);
|
|
125
|
+
const fetcher: FetchLike = async (input, init) => {
|
|
126
|
+
const response = await baseFetch(input, init);
|
|
127
|
+
if (response.status === 401 || response.status === 403) {
|
|
128
|
+
const challenge = extractWWWAuthenticateParams(response);
|
|
129
|
+
this.challenges.set(name, { ...(challenge.resourceMetadataUrl ? { resourceMetadataUrl: challenge.resourceMetadataUrl } : {}), ...(challenge.scope ? { scope: challenge.scope } : {}) });
|
|
130
|
+
}
|
|
131
|
+
return response;
|
|
132
|
+
};
|
|
133
|
+
for (const [header, variable] of Object.entries(config.headersEnv ?? {})) {
|
|
134
|
+
const value = env[variable]; if (!value) throw new McpFailure("auth_required"); headers.set(header, value);
|
|
135
|
+
}
|
|
136
|
+
const provider = config.oauth ? await this.provider(name, config) : undefined;
|
|
137
|
+
if (config.bearerTokenEnv && !env[config.bearerTokenEnv]) throw new McpFailure("auth_required");
|
|
138
|
+
transport = new StreamableHTTPClientTransport(new URL(config.url), { requestInit: { headers, redirect: "manual" }, fetch: fetcher,
|
|
139
|
+
// A token-only provider has no onUnauthorized callback: the SDK cannot re-authorize and resend a business call.
|
|
140
|
+
...((provider || config.bearerTokenEnv) ? { authProvider: { token: async () => provider ? closingTransport ? provider.tokens()?.access_token : await provider.token(fetcher) : env[config.bearerTokenEnv!] } } : {}),
|
|
141
|
+
onInsufficientScope: "throw", reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 1_000, maxReconnectionDelay: 30_000, reconnectionDelayGrowFactor: 1.5 } });
|
|
142
|
+
}
|
|
143
|
+
const cancel = () => { void transport.close(); };
|
|
144
|
+
let shuttingDown: Promise<void> | undefined;
|
|
145
|
+
const shutdown = () => shuttingDown ??= (async () => {
|
|
146
|
+
closingTransport = true;
|
|
147
|
+
try {
|
|
148
|
+
if (transport instanceof StreamableHTTPClientTransport) await transport.terminateSession();
|
|
149
|
+
} catch (error) { throw new McpFailure(fault(error).code); }
|
|
150
|
+
finally { await client.close(); }
|
|
151
|
+
})();
|
|
152
|
+
combined.addEventListener("abort", cancel, { once: true });
|
|
153
|
+
try {
|
|
154
|
+
await client.connect(transport, { signal: combined, timeout, maxTotalTimeout: timeout });
|
|
155
|
+
const discovered = await client.listTools(undefined, { signal: combined, timeout, cacheMode: "bypass" });
|
|
156
|
+
if (discovered.tools.length > 1_024) throw new McpFailure("protocol_error");
|
|
157
|
+
const names = new Set(discovered.tools.map(tool => tool.name));
|
|
158
|
+
if (names.size !== discovered.tools.length) throw new McpFailure("protocol_error");
|
|
159
|
+
const tools = immutable(structuredClone(discovered.tools));
|
|
160
|
+
for (const tool of tools) { schemas.validator(tool.inputSchema); if (tool.outputSchema) schemas.validator(tool.outputSchema); }
|
|
161
|
+
const opened: Opened = { server: name, client, transport, tools, schemas, catalog: JSON.stringify(tools), dirty: false,
|
|
162
|
+
close: async () => { combined.removeEventListener("abort", cancel); try { await shutdown(); } finally { this.opened.delete(opened); } } };
|
|
163
|
+
for (const method of ["notifications/tools/list_changed", "notifications/prompts/list_changed", "notifications/resources/list_changed"] as const)
|
|
164
|
+
client.setNotificationHandler(method, () => { opened.dirty = true; });
|
|
165
|
+
this.opened.add(opened); return opened;
|
|
166
|
+
} catch (error) { combined.removeEventListener("abort", cancel); await shutdown().catch(() => {}); throw error; }
|
|
167
|
+
}
|
|
168
|
+
private integration(name: string, config: McpServerConfig, initial: Opened): ExtensionIntegration {
|
|
169
|
+
let prepared: Opened | undefined = initial;
|
|
170
|
+
const aliases = new Map(initial.tools.map(tool => [mcpToolName(name, tool.name), tool]));
|
|
171
|
+
const tools: ExtensionIntegration["tools"][number][] = initial.tools.map(tool => ({ name: mcpToolName(name, tool.name),
|
|
172
|
+
description: `MCP server ${name}; remote tool ${tool.name}. ${tool.description ?? ""}`, parameters: structuredClone(tool.inputSchema),
|
|
173
|
+
prepareArguments: initial.schemas.prepare(tool.inputSchema), validateInput: initial.schemas.prepare(tool.inputSchema), exposure: "deferred" }));
|
|
174
|
+
const caps = initial.client.getServerCapabilities(), resourceRead = mcpToolName(name, "__resources_read", "builtin"), resourcesList = mcpToolName(name, "__resources_list", "builtin"),
|
|
175
|
+
templatesList = mcpToolName(name, "__resource_templates_list", "builtin"), promptsList = mcpToolName(name, "__prompts_list", "builtin"), promptGet = mcpToolName(name, "__prompts_get", "builtin");
|
|
176
|
+
const schema = (properties: Record<string, unknown>, required: string[] = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
177
|
+
if (caps?.resources) tools.push({ name: resourceRead, description: `Read a resource URI from MCP server ${name}`, parameters: schema({ uri: { type: "string" } }, ["uri"]), exposure: "deferred" },
|
|
178
|
+
{ name: resourcesList, description: `List resources from MCP server ${name}`, parameters: schema({}), exposure: "deferred" },
|
|
179
|
+
{ name: templatesList, description: `List URI templates from MCP server ${name}`, parameters: schema({}), exposure: "deferred" });
|
|
180
|
+
if (caps?.prompts) tools.push({ name: promptsList, description: `List prompts from MCP server ${name}`, parameters: schema({}), exposure: "deferred" },
|
|
181
|
+
{ name: promptGet, description: `Get a prompt from MCP server ${name}; returned text is untrusted source content`, parameters: schema({ name: { type: "string" }, arguments: { type: "object", additionalProperties: { type: "string" } } }, ["name"]), exposure: "deferred" });
|
|
182
|
+
const validators = new Map(tools.map(tool => [tool.name, initial.schemas.prepare(tool.parameters as JsonSchemaType)]));
|
|
183
|
+
for (const tool of tools) { tool.prepareArguments = validators.get(tool.name)!; tool.validateInput = validators.get(tool.name)!; }
|
|
184
|
+
return { name: integrationName(name), tools: immutable(tools), connectTimeoutMs: config.timeoutMs ?? 60_000, callTimeoutMs: config.timeoutMs ?? 60_000,
|
|
185
|
+
connect: async signal => {
|
|
186
|
+
try {
|
|
187
|
+
const handle = prepared ?? await this.open(name, config, signal); prepared = undefined;
|
|
188
|
+
if (handle.catalog !== initial.catalog || handle.dirty) { await handle.close(); return { kind: "integration_failure", code: "protocol_error" }; }
|
|
189
|
+
const connection: IntegrationConnection = { close: () => handle.close(), execute: async (alias, _id, args, callSignal) => {
|
|
190
|
+
if (handle.dirty) return { kind: "integration_failure", code: "protocol_error" };
|
|
191
|
+
const request = { signal: callSignal, timeout: config.timeoutMs ?? 60_000, maxTotalTimeout: config.timeoutMs ?? 60_000 };
|
|
192
|
+
try {
|
|
193
|
+
const validate = validators.get(alias); if (!validate) throw new McpFailure("protocol_error");
|
|
194
|
+
// Direct adapter callers still cannot send an invalid business request.
|
|
195
|
+
validate(args);
|
|
196
|
+
const tool = aliases.get(alias);
|
|
197
|
+
if (tool) {
|
|
198
|
+
const result = await handle.client.callTool({ name: tool.name, arguments: args }, { ...request, toolDefinition: tool });
|
|
199
|
+
return contentResult(result.content, result, result.isError);
|
|
200
|
+
}
|
|
201
|
+
let result: unknown;
|
|
202
|
+
if (caps?.resources && alias === resourceRead && typeof args["uri"] === "string") result = await handle.client.readResource({ uri: args["uri"] }, { ...request, cacheMode: "bypass" });
|
|
203
|
+
else if (caps?.resources && alias === resourcesList) result = await handle.client.listResources(undefined, { ...request, cacheMode: "bypass" });
|
|
204
|
+
else if (caps?.resources && alias === templatesList) result = await handle.client.listResourceTemplates(undefined, { ...request, cacheMode: "bypass" });
|
|
205
|
+
else if (caps?.prompts && alias === promptsList) result = await handle.client.listPrompts(undefined, { ...request, cacheMode: "bypass" });
|
|
206
|
+
else if (caps?.prompts && alias === promptGet && typeof args["name"] === "string") {
|
|
207
|
+
const values = args["arguments"];
|
|
208
|
+
if (values !== undefined && (!values || typeof values !== "object" || Array.isArray(values) || Object.values(values).some(value => typeof value !== "string"))) throw new McpFailure("protocol_error");
|
|
209
|
+
result = await handle.client.getPrompt({ name: args["name"], ...(values ? { arguments: values as Record<string, string> } : {}) }, request);
|
|
210
|
+
} else throw new McpFailure("protocol_error");
|
|
211
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], ...(isJsonValue(result) ? { details: result } : {}) };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof ToolFault) return { isError: true, content: [{ type: "text", text: error.message }] };
|
|
214
|
+
const failure = fault(error);
|
|
215
|
+
this.statuses.set(name, { name, transport: config.transport, state: failure.code, tools: tools.length });
|
|
216
|
+
return failure;
|
|
217
|
+
}
|
|
218
|
+
} }; return connection;
|
|
219
|
+
} catch (error) { return fault(error); }
|
|
220
|
+
} };
|
|
221
|
+
}
|
|
222
|
+
async discover(signal?: AbortSignal): Promise<{ integrations: ExtensionIntegration[]; statuses: McpServerStatus[] }> {
|
|
223
|
+
// Registered generations keep ownership until disposal; rediscovery must never overlap their connections.
|
|
224
|
+
if (this.discovered) throw new McpFailure("protocol_error");
|
|
225
|
+
this.discovered = true;
|
|
226
|
+
const combined = AbortSignal.any([this.lifetime.signal, ...(signal ? [signal] : [])]), integrations: ExtensionIntegration[] = [];
|
|
227
|
+
for (const [name, config] of Object.entries(this.servers)) {
|
|
228
|
+
if (config.enabled === false) { this.statuses.set(name, { name, transport: config.transport, state: "disabled", tools: 0 }); continue; }
|
|
229
|
+
try {
|
|
230
|
+
const opened = await this.open(name, config, combined), definition = this.integration(name, config, opened);
|
|
231
|
+
if (definition.tools.length) integrations.push(definition);
|
|
232
|
+
this.statuses.set(name, { name, transport: config.transport, state: "ready", tools: definition.tools.length });
|
|
233
|
+
} catch (error) { this.statuses.set(name, { name, transport: config.transport, state: fault(error).code, tools: 0,
|
|
234
|
+
...(error instanceof McpSchemaError ? { diagnostic: error.message } : {}) }); }
|
|
235
|
+
}
|
|
236
|
+
return { integrations, statuses: this.status() };
|
|
237
|
+
}
|
|
238
|
+
status(): McpServerStatus[] { return [...this.statuses.values()].map(status => ({ ...status })); }
|
|
239
|
+
async login(name: string, onAuthorizationUrl: (url: URL) => void | Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
240
|
+
const config = this.servers[name];
|
|
241
|
+
if (!config || config.transport !== "http" || config.oauth === false || config.bearerTokenEnv) throw new McpAuthRequired();
|
|
242
|
+
const timeout = AbortSignal.timeout(300_000), combined = AbortSignal.any([timeout, this.lifetime.signal, ...(signal ? [signal] : [])]);
|
|
243
|
+
try { await (await this.provider(name, config)).login(onAuthorizationUrl, this.fetch(combined, 30_000), combined); }
|
|
244
|
+
catch { throw new McpAuthRequired(); }
|
|
245
|
+
}
|
|
246
|
+
async logout(name: string): Promise<void> {
|
|
247
|
+
const config = this.servers[name]; if (!config || config.transport !== "http") throw new McpAuthRequired();
|
|
248
|
+
await Promise.all([...this.opened].filter(handle => handle.server === name).map(handle => handle.close()));
|
|
249
|
+
await (await this.provider(name, config)).invalidateCredentials("all"); this.oauth.delete(name);
|
|
250
|
+
this.statuses.set(name, { name, transport: config.transport, state: "auth_required", tools: 0 });
|
|
251
|
+
}
|
|
252
|
+
dispose(): Promise<void> {
|
|
253
|
+
if (this.disposal) return this.disposal;
|
|
254
|
+
this.lifetime.abort();
|
|
255
|
+
return this.disposal = (async () => {
|
|
256
|
+
const results = await Promise.allSettled([...this.opened].map(opened => opened.close()));
|
|
257
|
+
const failure = results.find(result => result.status === "rejected");
|
|
258
|
+
if (failure?.status === "rejected") throw new McpFailure(fault(failure.reason).code);
|
|
259
|
+
})();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** JsonValue is intentionally used only after validation; remote schema/result types never own evidence. */
|
|
264
|
+
export function mcpStatusData(statuses: readonly McpServerStatus[]): JsonValue { return statuses.map(status => ({ ...status })); }
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** OAuth is a user-owned repair operation; the SDK owns discovery, PKCE and issuer/resource binding. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { auth, checkResourceAllowed, resourceUrlFromServerUrl, StreamableHTTPClientTransport,
|
|
4
|
+
type OAuthClientProvider, type OAuthClientInformationContext, type StoredOAuthClientInformation, type StoredOAuthTokens,
|
|
5
|
+
type OAuthClientMetadata, type OAuthDiscoveryState, type FetchLike } from "@modelcontextprotocol/client";
|
|
6
|
+
import type { McpOAuthConfig } from "../core/mcp-config.ts";
|
|
7
|
+
import { isJsonValue } from "../core/tool-contract.ts";
|
|
8
|
+
import type { McpCredentials, McpCredentialsStore } from "../store/mcp-credentials.ts";
|
|
9
|
+
import { boundedResponseBytes } from "./web-http.ts";
|
|
10
|
+
|
|
11
|
+
export class McpAuthRequired extends Error { constructor() { super("MCP authentication requires an explicit login"); this.name = "McpAuthRequired"; } }
|
|
12
|
+
/** The SDK warns with OAuth error_description; normalize peer errors before they can reach its console path. */
|
|
13
|
+
export function safeOAuthFetch(fetcher: FetchLike): FetchLike {
|
|
14
|
+
return async (input, init) => {
|
|
15
|
+
let response: Response;
|
|
16
|
+
try { response = await fetcher(input, init); } catch { throw new McpAuthRequired(); }
|
|
17
|
+
if (response.ok) return response;
|
|
18
|
+
let code = "server_error";
|
|
19
|
+
try {
|
|
20
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(await boundedResponseBytes(response, 16 * 1024)));
|
|
21
|
+
if (value && typeof value === "object" && "error" in value && typeof value.error === "string"
|
|
22
|
+
&& ["invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type", "invalid_scope", "access_denied", "server_error", "temporarily_unavailable"].includes(value.error)) code = value.error;
|
|
23
|
+
} catch { /* Unknown peer bodies contribute status only, never diagnostics containing secrets. */ }
|
|
24
|
+
return Response.json({ error: code, error_description: "OAuth request rejected" }, { status: response.status, headers: { "content-type": "application/json" } });
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function savedClient(value: unknown): value is StoredOAuthClientInformation {
|
|
28
|
+
return !!value && typeof value === "object" && "client_id" in value && typeof value.client_id === "string";
|
|
29
|
+
}
|
|
30
|
+
function savedTokens(value: unknown): value is StoredOAuthTokens {
|
|
31
|
+
return !!value && typeof value === "object" && "access_token" in value && typeof value.access_token === "string"
|
|
32
|
+
&& "token_type" in value && typeof value.token_type === "string";
|
|
33
|
+
}
|
|
34
|
+
/** A per-resource provider; discovery/verifier/state exist only during this owned process's login. */
|
|
35
|
+
export class McpOAuthProvider implements OAuthClientProvider {
|
|
36
|
+
private readonly url: string;
|
|
37
|
+
private readonly config: McpOAuthConfig;
|
|
38
|
+
private readonly store: McpCredentialsStore;
|
|
39
|
+
private readonly env: Readonly<Record<string, string | undefined>>;
|
|
40
|
+
private credentials: McpCredentials;
|
|
41
|
+
private verifier: string | undefined;
|
|
42
|
+
private discovery: OAuthDiscoveryState | undefined;
|
|
43
|
+
private expectedState: string | undefined;
|
|
44
|
+
private redirect: ((url: URL) => void | Promise<void>) | undefined;
|
|
45
|
+
private refreshing: Promise<void> | undefined;
|
|
46
|
+
private resourceMetadataUrl: URL | undefined;
|
|
47
|
+
private challengedScope: string | undefined;
|
|
48
|
+
private constructor(url: string, config: McpOAuthConfig,
|
|
49
|
+
store: McpCredentialsStore, env: Readonly<Record<string, string | undefined>>, credentials?: McpCredentials) {
|
|
50
|
+
this.url = url; this.config = config; this.store = store; this.env = env;
|
|
51
|
+
this.credentials = credentials ?? { version: 1, serverUrl: url, redirectUrl: config.redirectUrl ?? "http://127.0.0.1:0/callback",
|
|
52
|
+
clients: {}, tokens: {}, expiresAt: {}, activeIssuer: null, ...(config.clientId ? { clientIdentity: config.clientId } : {}) };
|
|
53
|
+
if (config.redirectUrl) this.credentials.redirectUrl = config.redirectUrl;
|
|
54
|
+
}
|
|
55
|
+
static async create(url: string, config: McpOAuthConfig, store: McpCredentialsStore, env: Readonly<Record<string, string | undefined>>) {
|
|
56
|
+
return new McpOAuthProvider(url, config, store, env, await store.read(url, config.clientId));
|
|
57
|
+
}
|
|
58
|
+
get redirectUrl() { return this.credentials.redirectUrl; }
|
|
59
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
60
|
+
return { client_name: "xeno", application_type: "native", redirect_uris: [this.redirectUrl], response_types: ["code"],
|
|
61
|
+
grant_types: ["authorization_code", "refresh_token"], token_endpoint_auth_method: this.config.clientSecretEnv ? "client_secret_basic" : "none",
|
|
62
|
+
...(this.config.scope ? { scope: this.config.scope } : {}) };
|
|
63
|
+
}
|
|
64
|
+
clientInformation(ctx?: OAuthClientInformationContext): StoredOAuthClientInformation | undefined {
|
|
65
|
+
if (this.config.clientId) {
|
|
66
|
+
const secret = this.config.clientSecretEnv ? this.env[this.config.clientSecretEnv] : undefined;
|
|
67
|
+
if (this.config.clientSecretEnv && !secret) throw new McpAuthRequired();
|
|
68
|
+
return { client_id: this.config.clientId, ...(secret ? { client_secret: secret } : {}), ...(ctx ? { issuer: ctx.issuer } : {}) };
|
|
69
|
+
}
|
|
70
|
+
const value = ctx ? this.credentials.clients[ctx.issuer] : undefined;
|
|
71
|
+
return savedClient(value) ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
async saveClientInformation(info: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext) {
|
|
74
|
+
const issuer = ctx?.issuer ?? info.issuer;
|
|
75
|
+
if (!issuer || !isJsonValue(info)) throw new McpAuthRequired();
|
|
76
|
+
this.credentials.clients[issuer] = info;
|
|
77
|
+
await this.store.write(this.credentials);
|
|
78
|
+
}
|
|
79
|
+
tokens(ctx?: OAuthClientInformationContext): StoredOAuthTokens | undefined {
|
|
80
|
+
const issuer = ctx?.issuer ?? this.credentials.activeIssuer, value = issuer ? this.credentials.tokens[issuer] : undefined;
|
|
81
|
+
return savedTokens(value) ? value : undefined;
|
|
82
|
+
}
|
|
83
|
+
async saveTokens(tokens: StoredOAuthTokens, ctx?: OAuthClientInformationContext) {
|
|
84
|
+
const issuer = ctx?.issuer ?? tokens.issuer;
|
|
85
|
+
if (!issuer || !isJsonValue(tokens)) throw new McpAuthRequired();
|
|
86
|
+
this.credentials.tokens[issuer] = tokens; this.credentials.activeIssuer = issuer;
|
|
87
|
+
if (tokens.expires_in !== undefined) this.credentials.expiresAt[issuer] = Date.now() + tokens.expires_in * 1_000;
|
|
88
|
+
else delete this.credentials.expiresAt[issuer];
|
|
89
|
+
await this.store.write(this.credentials);
|
|
90
|
+
}
|
|
91
|
+
state() { this.expectedState = randomUUID(); return this.expectedState; }
|
|
92
|
+
redirectToAuthorization(url: URL) {
|
|
93
|
+
if (!this.redirect) throw new McpAuthRequired();
|
|
94
|
+
return this.redirect(url);
|
|
95
|
+
}
|
|
96
|
+
saveCodeVerifier(verifier: string) { this.verifier = verifier; }
|
|
97
|
+
codeVerifier() { if (!this.verifier) throw new McpAuthRequired(); return this.verifier; }
|
|
98
|
+
saveDiscoveryState(state: OAuthDiscoveryState) { this.discovery = state; }
|
|
99
|
+
discoveryState() { return this.discovery; }
|
|
100
|
+
setChallenge(resourceMetadataUrl: URL | undefined, scope: string | undefined) {
|
|
101
|
+
this.resourceMetadataUrl = resourceMetadataUrl; this.challengedScope = scope; this.discovery = undefined;
|
|
102
|
+
}
|
|
103
|
+
private authOptions(fetcher: FetchLike) {
|
|
104
|
+
const scope = this.config.scope ?? this.challengedScope;
|
|
105
|
+
return { serverUrl: this.url, fetchFn: safeOAuthFetch(fetcher), ...(this.resourceMetadataUrl ? { resourceMetadataUrl: this.resourceMetadataUrl } : {}), ...(scope ? { scope } : {}) };
|
|
106
|
+
}
|
|
107
|
+
async validateResourceURL(serverUrl: string | URL, resource?: string) {
|
|
108
|
+
const expected = resourceUrlFromServerUrl(serverUrl);
|
|
109
|
+
if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) throw new McpAuthRequired();
|
|
110
|
+
return expected;
|
|
111
|
+
}
|
|
112
|
+
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
|
|
113
|
+
if (scope === "all" || scope === "client") this.credentials.clients = {};
|
|
114
|
+
if (scope === "all" || scope === "tokens") { this.credentials.tokens = {}; this.credentials.expiresAt = {}; this.credentials.activeIssuer = null; }
|
|
115
|
+
if (scope === "all" || scope === "verifier") this.verifier = undefined;
|
|
116
|
+
if (scope === "all" || scope === "discovery") this.discovery = undefined;
|
|
117
|
+
if (scope === "all") await this.store.remove(this.url, this.config.clientId); else await this.store.write(this.credentials);
|
|
118
|
+
}
|
|
119
|
+
async token(fetcher: FetchLike): Promise<string | undefined> {
|
|
120
|
+
const issuer = this.credentials.activeIssuer, expiresAt = issuer ? this.credentials.expiresAt[issuer] : undefined;
|
|
121
|
+
// Assumed 30s skew prevents sending a token that expires while the new request is in flight.
|
|
122
|
+
if (expiresAt !== undefined && expiresAt <= Date.now() + 30_000) {
|
|
123
|
+
if (!this.refreshing) {
|
|
124
|
+
this.refreshing = (async () => {
|
|
125
|
+
if (!this.tokens()?.refresh_token) { await this.invalidateCredentials("tokens"); throw new McpAuthRequired(); }
|
|
126
|
+
try { if (await auth(this, this.authOptions(fetcher)) !== "AUTHORIZED") throw new McpAuthRequired(); }
|
|
127
|
+
catch { await this.invalidateCredentials("tokens"); throw new McpAuthRequired(); }
|
|
128
|
+
})().finally(() => { this.refreshing = undefined; });
|
|
129
|
+
}
|
|
130
|
+
await this.refreshing;
|
|
131
|
+
}
|
|
132
|
+
return this.tokens()?.access_token;
|
|
133
|
+
}
|
|
134
|
+
async login(onAuthorizationUrl: (url: URL) => void | Promise<void>, fetcher: FetchLike, signal: AbortSignal): Promise<void> {
|
|
135
|
+
if (this.redirect) throw new McpAuthRequired();
|
|
136
|
+
signal.throwIfAborted();
|
|
137
|
+
const callback = new URL(this.redirectUrl);
|
|
138
|
+
let resolveCallback: (value: URLSearchParams) => void = () => {}, rejectCallback: (error: Error) => void = () => {};
|
|
139
|
+
const received = new Promise<URLSearchParams>((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject; });
|
|
140
|
+
// Reject handlers are installed before starting auth; cancellation never leaves an unhandled callback promise.
|
|
141
|
+
void received.catch(() => {});
|
|
142
|
+
const server = Bun.serve({ hostname: "127.0.0.1", port: Number(callback.port), fetch: request => {
|
|
143
|
+
const current = new URL(request.url);
|
|
144
|
+
if (current.pathname !== callback.pathname) return new Response("Not found", { status: 404 });
|
|
145
|
+
if (request.method !== "GET" || !this.expectedState || current.searchParams.get("state") !== this.expectedState) return new Response("Invalid authorization state", { status: 400 });
|
|
146
|
+
if (current.searchParams.has("error") || !current.searchParams.get("code")) {
|
|
147
|
+
rejectCallback(new McpAuthRequired()); return new Response("Authorization was not completed", { status: 400 });
|
|
148
|
+
}
|
|
149
|
+
resolveCallback(current.searchParams); return new Response("Authorization received. Return to xeno.", { headers: { "content-type": "text/plain" } });
|
|
150
|
+
} });
|
|
151
|
+
const selected = new URL(callback.pathname, server.url).href;
|
|
152
|
+
if (selected !== this.credentials.redirectUrl && !this.config.clientId) this.credentials.clients = {};
|
|
153
|
+
this.credentials.redirectUrl = selected; this.redirect = onAuthorizationUrl;
|
|
154
|
+
const onAbort = () => rejectCallback(new McpAuthRequired());
|
|
155
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
156
|
+
try {
|
|
157
|
+
const safeFetch = safeOAuthFetch(fetcher);
|
|
158
|
+
const result = await auth(this, this.authOptions(fetcher));
|
|
159
|
+
if (result === "AUTHORIZED") return;
|
|
160
|
+
const params = await received;
|
|
161
|
+
const transport = new StreamableHTTPClientTransport(new URL(this.url), { authProvider: this, fetch: safeFetch, onInsufficientScope: "throw" });
|
|
162
|
+
try { await transport.finishAuth(params); } finally { await transport.close(); }
|
|
163
|
+
} finally {
|
|
164
|
+
signal.removeEventListener("abort", onAbort); server.stop(true); this.redirect = undefined;
|
|
165
|
+
this.expectedState = undefined; this.verifier = undefined; this.discovery = undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** MCP 2025-11-25 defaults to 2020-12; validation never repairs the frozen invocation. */
|
|
2
|
+
import { Ajv, type ValidateFunction } from "ajv";
|
|
3
|
+
import { Ajv2019 } from "ajv/dist/2019.js";
|
|
4
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
5
|
+
import { addFormats } from "@modelcontextprotocol/client/validators/ajv";
|
|
6
|
+
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from "@modelcontextprotocol/client";
|
|
7
|
+
import { NOT_STARTED, ToolFault } from "../core/tool-failure.ts";
|
|
8
|
+
|
|
9
|
+
export class McpSchemaError extends Error {
|
|
10
|
+
constructor() { super("MCP schema is invalid or uses an unsupported dialect, reference, keyword or format; repair the server schema before reloading."); this.name = "McpSchemaError"; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The official client's format package is bundled in its public validator entry; no extra dependency. */
|
|
14
|
+
export class McpSchemas implements jsonSchemaValidator {
|
|
15
|
+
private readonly compiled = new Map<string, ValidateFunction>();
|
|
16
|
+
validator(schema: unknown): ValidateFunction {
|
|
17
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) throw new McpSchemaError();
|
|
18
|
+
const key = JSON.stringify(schema), cached = this.compiled.get(key);
|
|
19
|
+
if (cached) return cached;
|
|
20
|
+
try {
|
|
21
|
+
const dialect = (schema as Record<string, unknown>)["$schema"];
|
|
22
|
+
const options = { strictSchema: true, strictNumbers: true, strictTypes: false, strictTuples: false, strictRequired: false,
|
|
23
|
+
validateFormats: true, coerceTypes: false, useDefaults: false, removeAdditional: false, allErrors: false, logger: false as const };
|
|
24
|
+
// Strict schema rejects unknown semantics, while strict style checks are disabled:
|
|
25
|
+
// valid JSON Schema need not spell out every tuple bound or repeat inherited types.
|
|
26
|
+
const ajv = dialect === undefined || dialect === "https://json-schema.org/draft/2020-12/schema" ? new Ajv2020(options)
|
|
27
|
+
: dialect === "https://json-schema.org/draft/2019-09/schema" ? new Ajv2019(options)
|
|
28
|
+
: dialect === "http://json-schema.org/draft-07/schema#" || dialect === "http://json-schema.org/draft-07/schema" ? new Ajv(options)
|
|
29
|
+
: undefined;
|
|
30
|
+
if (!ajv) throw new McpSchemaError();
|
|
31
|
+
addFormats(ajv);
|
|
32
|
+
// MCP's header annotation is interpreted by the official transport, not a validation constraint.
|
|
33
|
+
ajv.addKeyword({ keyword: "x-mcp-header", schemaType: "string", valid: true });
|
|
34
|
+
// Ajv validates this entire JSON schema; the assertion only bridges the SDK's wider wire type.
|
|
35
|
+
const validate = ajv.compile(structuredClone(schema) as JsonSchemaType);
|
|
36
|
+
this.compiled.set(key, validate); return validate;
|
|
37
|
+
} catch { throw new McpSchemaError(); }
|
|
38
|
+
}
|
|
39
|
+
getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
|
|
40
|
+
const validate = this.validator(schema);
|
|
41
|
+
return input => validate(input) ? { valid: true, data: input as T, errorMessage: undefined }
|
|
42
|
+
: { valid: false, data: undefined, errorMessage: "MCP structured output failed the advertised schema." };
|
|
43
|
+
}
|
|
44
|
+
prepare(schema: unknown): (input: unknown) => unknown {
|
|
45
|
+
const validate = this.validator(schema);
|
|
46
|
+
return input => {
|
|
47
|
+
if (!validate(input)) throw new ToolFault("invalid_input", "MCP arguments do not satisfy the complete advertised schema. Correct the input before calling the remote tool.", NOT_STARTED);
|
|
48
|
+
return input;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
|
|
|
18
18
|
import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
|
|
19
19
|
|
|
20
20
|
/** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
|
|
21
|
-
export const XENO_VERSION = "0.
|
|
21
|
+
export const XENO_VERSION = "0.7.0";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Minutes subtracted from server expiry so a request issued at the edge
|
|
@@ -24,6 +24,7 @@ import { assertNever } from "../core/exhaustive.ts";
|
|
|
24
24
|
import type { OnDelta, Provider, ProviderConfig, ToolSchema, Turn } from "./provider.ts";
|
|
25
25
|
import { pushSse, HttpError, StreamError } from "./openai.ts";
|
|
26
26
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
27
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
27
28
|
|
|
28
29
|
/** What this adapter parks in a thinking block's raw: the reasoning item, whole. */
|
|
29
30
|
interface ResponsesRaw { responses_item: Record<string, unknown> }
|
|
@@ -266,19 +267,20 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
266
267
|
this.onRetry = options.onRetry;
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
270
|
-
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), this.retry, undefined,
|
|
270
|
+
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
271
|
+
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, options), this.retry, undefined,
|
|
271
272
|
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.onRetry?.(attempt, delayMs, error); }, signal);
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
275
|
+
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
275
276
|
const started = Date.now();
|
|
276
277
|
const baseUrl = this.config.spec.baseUrl;
|
|
277
278
|
if (baseUrl === undefined) throw new Error(`model ${this.config.spec.id} has no baseUrl to send to`);
|
|
278
279
|
const init: RequestInit = {
|
|
279
280
|
method: "POST",
|
|
280
281
|
headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
281
|
-
body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true
|
|
282
|
+
body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true,
|
|
283
|
+
...(options?.toolUse === "disabled" ? { tool_choice: "none" } : {}) }),
|
|
282
284
|
};
|
|
283
285
|
if (signal !== undefined) init.signal = signal;
|
|
284
286
|
const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, init);
|
package/src/runtime/openai.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { assertNever } from "../core/exhaustive.ts";
|
|
|
26
26
|
import type { Provider, ProviderConfig, Turn, ToolSchema, OnDelta } from "./provider.ts";
|
|
27
27
|
import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
|
|
28
28
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
29
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
29
30
|
|
|
30
31
|
/** One message as Chat Completions wants it. */
|
|
31
32
|
export interface WireMessage {
|
|
@@ -48,6 +49,7 @@ export interface WireRequest {
|
|
|
48
49
|
max_tokens?: number;
|
|
49
50
|
max_completion_tokens?: number;
|
|
50
51
|
tools?: unknown[];
|
|
52
|
+
tool_choice?: "none";
|
|
51
53
|
thinking?: { type: "enabled" | "disabled" };
|
|
52
54
|
reasoning_effort?: string;
|
|
53
55
|
}
|
|
@@ -434,11 +436,12 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
434
436
|
node: number,
|
|
435
437
|
signal?: AbortSignal,
|
|
436
438
|
onDelta?: OnDelta,
|
|
439
|
+
options?: ProviderSendOptions,
|
|
437
440
|
): Promise<Turn> {
|
|
438
441
|
// The clock starts inside the retried request, so a reported duration is
|
|
439
442
|
// one attempt rather than the sum of the ones that failed.
|
|
440
443
|
return withRetry(
|
|
441
|
-
() => this.attempt(frozen, messages, node, signal, onDelta),
|
|
444
|
+
() => this.attempt(frozen, messages, node, signal, onDelta, options),
|
|
442
445
|
this.retry,
|
|
443
446
|
undefined,
|
|
444
447
|
(attempt, delayMs, error) => {
|
|
@@ -456,6 +459,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
456
459
|
node: number,
|
|
457
460
|
signal?: AbortSignal,
|
|
458
461
|
onDelta?: OnDelta,
|
|
462
|
+
options?: ProviderSendOptions,
|
|
459
463
|
): Promise<Turn> {
|
|
460
464
|
const started = Date.now();
|
|
461
465
|
const baseUrl = this.config.spec.baseUrl;
|
|
@@ -468,6 +472,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
468
472
|
stream: true;
|
|
469
473
|
stream_options?: { include_usage: true };
|
|
470
474
|
} = { ...buildRequest(this.config, frozen, messages), stream: true };
|
|
475
|
+
if (options?.toolUse === "disabled") payload.tool_choice = "none";
|
|
471
476
|
// Without include_usage an OpenAI-dialect stream never reports usage, and
|
|
472
477
|
// the loop would treat the resulting zeros as a real prompt size. Servers
|
|
473
478
|
// that 400 on the field opt out via Compat.supportsUsageInStreaming.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Ajv owns JSON Schema semantics; validation never repairs model output (D11/D13). */
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
4
|
+
import { Ajv2019 } from "ajv/dist/2019.js";
|
|
5
|
+
import { addFormats } from "@modelcontextprotocol/client/validators/ajv";
|
|
6
|
+
|
|
7
|
+
export type OutputValidation = { ok: true; value: unknown } | { ok: false; error: string };
|
|
8
|
+
export function outputValidator(schema: unknown): (text: string) => OutputValidation {
|
|
9
|
+
if (typeof schema !== "boolean" && (schema === null || typeof schema !== "object" || Array.isArray(schema))) throw Error("output schema must be a JSON Schema object or boolean");
|
|
10
|
+
const dialect = typeof schema === "object" && schema !== null && "$schema" in schema ? String(schema.$schema) : "";
|
|
11
|
+
const options = { allErrors: true, strictSchema: true, strictTypes: false, strictTuples: false, strictRequired: false, validateFormats: true,
|
|
12
|
+
coerceTypes: false, useDefaults: false, removeAdditional: false, logger: false as const };
|
|
13
|
+
const ajv = dialect === "https://json-schema.org/draft/2020-12/schema" ? new Ajv2020(options)
|
|
14
|
+
: dialect === "https://json-schema.org/draft/2019-09/schema" ? new Ajv2019(options)
|
|
15
|
+
: dialect === "" || dialect === "http://json-schema.org/draft-07/schema#" || dialect === "http://json-schema.org/draft-07/schema" ? new Ajv(options) : undefined;
|
|
16
|
+
if (!ajv) throw Error("unsupported output schema dialect");
|
|
17
|
+
// The approved MCP client exposes Ajv's standard formats; share them rather than silently ignoring formats.
|
|
18
|
+
addFormats(ajv);
|
|
19
|
+
const validate = ajv.compile(schema);
|
|
20
|
+
return text => {
|
|
21
|
+
let value: unknown;
|
|
22
|
+
try { value = JSON.parse(text); } catch { return { ok: false, error: "final output is not JSON" }; }
|
|
23
|
+
if (!validate(value)) return { ok: false, error: ajv.errorsText(validate.errors, { separator: "; " }).slice(0, 2048) };
|
|
24
|
+
return { ok: true, value };
|
|
25
|
+
};
|
|
26
|
+
}
|