@nylorun/runtime 0.19.1-beta → 0.21.0-beta
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 +90 -0
- package/README.md +22 -13
- package/dist/adapters/streams/s2.js +5 -11
- package/dist/api/components.d.ts +326 -205
- package/dist/api/components.js +3 -3
- package/dist/api/http/app.js +2 -0
- package/dist/api/http/define.d.ts +3 -2
- package/dist/api/http/define.js +10 -11
- package/dist/api/http/routes/mcp.d.ts +9 -0
- package/dist/api/http/routes/mcp.js +32 -0
- package/dist/api/http/routes/vaults.d.ts +1 -9
- package/dist/api/http/routes/vaults.js +3 -103
- package/dist/api/openapi.js +37 -6
- package/dist/artifacts/media-types.d.ts +2 -0
- package/dist/artifacts/media-types.js +5 -0
- package/dist/execution/types.d.ts +1 -1
- package/dist/gates/http-tool.d.ts +7 -0
- package/dist/gates/http-tool.js +34 -3
- package/dist/gates/mcp-handler.d.ts +4 -1
- package/dist/gates/mcp-handler.js +64 -8
- package/dist/gates/tenant-vaults.d.ts +9 -15
- package/dist/gates/tenant-vaults.js +9 -17
- package/dist/gates/tool-client.js +36 -17
- package/dist/gates/tool-contract.d.ts +19 -3
- package/dist/gates/tool-contract.js +9 -3
- package/dist/gates/tool-gate.d.ts +2 -2
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +13 -3
- package/dist/harness-api/record.d.ts +7 -6
- package/dist/harness-api/record.js +99 -17
- package/dist/harness-api/server.js +35 -27
- package/dist/harness-api/start.js +2 -1
- package/dist/host/app.d.ts +3 -0
- package/dist/host/app.js +33 -4
- package/dist/host/config-for.d.ts +0 -2
- package/dist/host/config-for.js +0 -1
- package/dist/host/create-host.d.ts +6 -0
- package/dist/host/create-host.js +1 -0
- package/dist/host/gates.js +0 -1
- package/dist/host/main.js +24 -7
- package/dist/host/stack-config.d.ts +5 -8
- package/dist/host/stack-config.js +9 -20
- package/dist/index.d.ts +0 -1
- package/dist/infra/index.d.ts +2 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/readiness.d.ts +6 -6
- package/dist/infra/readiness.js +2 -5
- package/dist/keys/client.js +1 -9
- package/dist/keys/keys.d.ts +11 -13
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +671 -378
- package/dist/mcp/connect.d.ts +159 -13
- package/dist/mcp/connect.js +538 -55
- package/dist/mcp/pool.d.ts +11 -2
- package/dist/mcp/pool.js +74 -27
- package/dist/mcp/preview.d.ts +35 -0
- package/dist/mcp/preview.js +215 -0
- package/dist/mcp/snapshot.d.ts +79 -5
- package/dist/mcp/snapshot.js +145 -27
- package/dist/model/catalog.d.ts +14 -0
- package/dist/model/catalog.js +14 -1
- package/dist/model/pi-model.js +36 -2
- package/dist/openapi.json +644 -266
- package/dist/redact.d.ts +19 -1
- package/dist/redact.js +42 -13
- package/dist/sandbox/session-sandbox.d.ts +7 -0
- package/dist/sandbox/session-sandbox.js +89 -28
- package/dist/store/postgres/connect.d.ts +10 -0
- package/dist/store/postgres/connect.js +30 -3
- package/dist/store/postgres/control.d.ts +14 -0
- package/dist/store/postgres/control.js +187 -0
- package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
- package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
- package/dist/store/postgres/schema.d.ts +122 -261
- package/dist/store/postgres/schema.js +22 -36
- package/dist/store/postgres/store.js +16 -18
- package/dist/store/postgres/tenant.d.ts +14 -0
- package/dist/store/postgres/tenant.js +22 -2
- package/dist/store/types.d.ts +42 -8
- package/dist/streams/types.d.ts +6 -29
- package/dist/streams/types.js +5 -4
- package/dist/tenant/artifact-tool.d.ts +9 -1
- package/dist/tenant/artifact-tool.js +113 -9
- package/dist/tenant/auth.d.ts +16 -6
- package/dist/tenant/auth.js +41 -17
- package/dist/tenant/commands.js +10 -6
- package/dist/tenant/context.d.ts +9 -6
- package/dist/tenant/control.d.ts +26 -0
- package/dist/tenant/control.js +35 -0
- package/dist/tenant/effects.d.ts +2 -2
- package/dist/tenant/effects.js +20 -5
- package/dist/tenant/ephemeral.d.ts +0 -6
- package/dist/tenant/ephemeral.js +1 -1
- package/dist/tenant/http.d.ts +4 -2
- package/dist/tenant/http.js +4 -2
- package/dist/tenant/identity-file.d.ts +16 -12
- package/dist/tenant/identity-file.js +41 -29
- package/dist/tenant/issuers.d.ts +4 -3
- package/dist/tenant/issuers.js +22 -16
- package/dist/tenant/outbound.d.ts +14 -5
- package/dist/tenant/outbound.js +21 -9
- package/dist/tenant/reset.d.ts +2 -2
- package/dist/tenant/reset.js +8 -2
- package/dist/tenant/resource-server.d.ts +56 -0
- package/dist/tenant/resource-server.js +62 -0
- package/dist/tenant/runtime.d.ts +1 -1
- package/dist/tenant/runtime.js +16 -15
- package/dist/tenant/scheduler.d.ts +1 -1
- package/dist/tenant/scheduler.js +1 -1
- package/dist/tenant/sessions.js +20 -3
- package/dist/tenant/store-pg.js +9 -0
- package/dist/tenant/streams.d.ts +16 -22
- package/dist/tenant/streams.js +7 -87
- package/dist/tenant/sweep.js +9 -1
- package/dist/tenant/tool-results.d.ts +57 -0
- package/dist/tenant/tool-results.js +298 -0
- package/dist/tenant/tool-search.d.ts +43 -0
- package/dist/tenant/tool-search.js +100 -0
- package/dist/tenant/transcript.d.ts +108 -0
- package/dist/tenant/transcript.js +13 -0
- package/dist/tenant/types.d.ts +0 -14
- package/dist/vault/crypto.d.ts +0 -7
- package/dist/vault/crypto.js +0 -16
- package/dist/vault/headers.d.ts +17 -0
- package/dist/vault/headers.js +61 -0
- package/dist/vault/service.d.ts +11 -68
- package/dist/vault/service.js +83 -438
- package/dist/vault/sources.d.ts +20 -48
- package/dist/vault/sources.js +25 -189
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/streams/control.d.ts +0 -13
- package/dist/streams/control.js +0 -25
- package/dist/vault/oauth.d.ts +0 -55
- package/dist/vault/oauth.js +0 -186
package/dist/mcp/connect.js
CHANGED
|
@@ -1,14 +1,157 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
3
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
3
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { MCP_TOOL_DEFAULTS, SERVER_INSTRUCTIONS_MAX_CHARS, mcpToolSettings, schemaFromJSON, } from "@nylorun/core/define";
|
|
7
|
+
import { scrub, scrubValues } from "../redact.js";
|
|
8
|
+
import { guardedFetch, MAX_RESPONSE_BYTES, OutboundFailed, OutboundRefused, } from "../tenant/outbound.js";
|
|
9
|
+
import { credentialSecrets } from "../vault/headers.js";
|
|
10
|
+
import { normalizeToolName, } from "./snapshot.js";
|
|
11
|
+
/** How much of a failed answer's body the model sees. */
|
|
12
|
+
export const MCP_ERROR_BODY_CHARS = 2_000;
|
|
13
|
+
/**
|
|
14
|
+
* The largest `tools/call` answer the gate holds (R2b C11): 8 MiB, an HTTP tool's cap. Core
|
|
15
|
+
* stores what does not fit a tool result as an artifact (`tenant/tool-results.ts`).
|
|
16
|
+
*/
|
|
17
|
+
export const MCP_RESULT_MAX_BYTES = MAX_RESPONSE_BYTES;
|
|
18
|
+
/** A tool call that failed with a code the model can act on (R2b C7). */
|
|
19
|
+
export class McpCallFailed extends Error {
|
|
20
|
+
failure;
|
|
21
|
+
constructor(failure) {
|
|
22
|
+
super(failure.message);
|
|
23
|
+
this.failure = failure;
|
|
24
|
+
this.name = "McpCallFailed";
|
|
25
|
+
}
|
|
26
|
+
get code() {
|
|
27
|
+
return this.failure.code;
|
|
28
|
+
}
|
|
29
|
+
/** The failure the gate answered, as the class the loop's own call would have thrown. */
|
|
30
|
+
static from(failure) {
|
|
31
|
+
return failure.code === "credential_rejected" && failure.server !== undefined
|
|
32
|
+
? new CredentialRejected(failure.server, failure.vault)
|
|
33
|
+
: new McpCallFailed(failure);
|
|
34
|
+
}
|
|
35
|
+
/** The failed tool outcome the model sees. */
|
|
36
|
+
outcome() {
|
|
37
|
+
const failure = this.failure;
|
|
38
|
+
return {
|
|
39
|
+
kind: "failed",
|
|
40
|
+
code: failure.code,
|
|
41
|
+
message: failure.message,
|
|
42
|
+
retryable: failure.retryable,
|
|
43
|
+
...(failure.server === undefined ? {} : { server: failure.server }),
|
|
44
|
+
...(failure.vault === undefined ? {} : { vault: failure.vault }),
|
|
45
|
+
...(failure.redacted ? { redacted: failure.redacted } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A request the server answered `401` (R2b C1): it rejected the vault's credential, or wanted one
|
|
51
|
+
* and got none. Never retried, since nothing the gate holds changes between tries (Q4). A tool
|
|
52
|
+
* call that meets it fails with `credential_rejected` for the model to see; at discovery it is the
|
|
53
|
+
* server's `failed` diagnostic.
|
|
54
|
+
*/
|
|
55
|
+
export class CredentialRejected extends McpCallFailed {
|
|
56
|
+
server;
|
|
57
|
+
vault;
|
|
58
|
+
challenge;
|
|
59
|
+
constructor(server,
|
|
60
|
+
/** The scope of the vault whose credential was sent; absent when none was. */
|
|
61
|
+
vault,
|
|
62
|
+
/**
|
|
63
|
+
* The answer's `WWW-Authenticate`, which may name the server's protected-resource metadata
|
|
64
|
+
* (RFC 9728 `resource_metadata`): a tool preview reads it (R2b C12). Never shown to the model.
|
|
65
|
+
*/
|
|
66
|
+
challenge) {
|
|
67
|
+
super({
|
|
68
|
+
code: "credential_rejected",
|
|
69
|
+
message: vault === undefined
|
|
70
|
+
? `The MCP server '${server}' answered HTTP 401: it needs a credential, and the session's vaults hold none for it`
|
|
71
|
+
: `The MCP server '${server}' answered HTTP 401: it rejected the credential from the ${vault} vault`,
|
|
72
|
+
sent: true,
|
|
73
|
+
retryable: false,
|
|
74
|
+
server,
|
|
75
|
+
...(vault === undefined ? {} : { vault }),
|
|
76
|
+
});
|
|
77
|
+
this.server = server;
|
|
78
|
+
this.vault = vault;
|
|
79
|
+
this.challenge = challenge;
|
|
80
|
+
this.name = "CredentialRejected";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* A request its connection can no longer make, refused before it reached a tool: the server
|
|
85
|
+
* answered `404` to the connection's `Mcp-Session-Id` (the session is gone, MCP 2025-06-18
|
|
86
|
+
* "Session Management"), or the credential's `via` now sends the server's requests elsewhere.
|
|
87
|
+
* Whoever holds the connection drops it, opens it again and sends the call once more (R2b C7).
|
|
88
|
+
* When that fails too, it is `mcp.unreachable`: nothing ran.
|
|
89
|
+
*/
|
|
90
|
+
export class McpConnectionStale extends McpCallFailed {
|
|
91
|
+
constructor(server, reason) {
|
|
92
|
+
super({
|
|
93
|
+
code: "mcp.unreachable",
|
|
94
|
+
message: `The MCP server '${server}' ${reason}, and the call was not sent`,
|
|
95
|
+
sent: false,
|
|
96
|
+
retryable: true,
|
|
97
|
+
});
|
|
98
|
+
this.name = "McpConnectionStale";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A tool call that never reached its server: its connection could not be opened. */
|
|
102
|
+
export function unreachable(server, error) {
|
|
103
|
+
if (error instanceof McpCallFailed)
|
|
104
|
+
return error;
|
|
105
|
+
return unreachableFailure(server, error instanceof Error ? error.message : String(error));
|
|
106
|
+
}
|
|
107
|
+
/** The operation a request of the transport's fetch belongs to. */
|
|
108
|
+
const tracking = new AsyncLocalStorage();
|
|
109
|
+
function tracked(call, run) {
|
|
110
|
+
const tracker = {
|
|
111
|
+
secrets: new Set(),
|
|
112
|
+
sent: false,
|
|
113
|
+
settled: false,
|
|
114
|
+
...(call ? { calling: new AbortController() } : {}),
|
|
115
|
+
};
|
|
116
|
+
return tracking.run(tracker, () => run(tracker));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* An SDK `Client` as an `McpClient`. Its answers and failures carry no credential value sent
|
|
120
|
+
* with them (R2b C8), and a failed call is an `McpCallFailed` (C7).
|
|
121
|
+
*/
|
|
122
|
+
export function sdkClient(client, serverName) {
|
|
9
123
|
return {
|
|
10
|
-
listTools: (params, options) =>
|
|
11
|
-
|
|
124
|
+
listTools: (params, options) => tracked(false, async (tracker) => {
|
|
125
|
+
try {
|
|
126
|
+
const page = await client.listTools(params, options?.signal ? { signal: options.signal } : undefined);
|
|
127
|
+
return scrubbed(page, tracker);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
throw scrubError(error, tracker);
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
callTool: (params, options) => tracked(true, async (tracker) => {
|
|
134
|
+
const lost = tracker.calling.signal;
|
|
135
|
+
try {
|
|
136
|
+
const result = (await client.callTool(params, undefined, {
|
|
137
|
+
signal: options?.signal ? AbortSignal.any([options.signal, lost]) : lost,
|
|
138
|
+
}));
|
|
139
|
+
// An answer the stream did not count (an `sse` server answers on its event stream).
|
|
140
|
+
if (Buffer.byteLength(JSON.stringify(result)) > MCP_RESULT_MAX_BYTES)
|
|
141
|
+
throw tooLargeFailure(serverName);
|
|
142
|
+
const answer = scrubValues(result, [...tracker.secrets]);
|
|
143
|
+
return { result: answer.value, ...(answer.redacted > 0 ? { redacted: answer.redacted } : {}) };
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
// A cancel or a shutdown: the caller decides what it means.
|
|
147
|
+
if (options?.signal?.aborted)
|
|
148
|
+
throw error;
|
|
149
|
+
throw scrubError(failureOf(serverName, error, tracker), tracker);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
tracker.settled = true;
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
12
155
|
};
|
|
13
156
|
}
|
|
14
157
|
export async function openMcpServer(input) {
|
|
@@ -20,76 +163,192 @@ export async function openMcpServer(input) {
|
|
|
20
163
|
initial.credentialIds;
|
|
21
164
|
throw error;
|
|
22
165
|
}
|
|
23
|
-
|
|
24
|
-
|
|
166
|
+
// A credential with `via` sends the server's requests there (R2b C2); the manifest's URL still
|
|
167
|
+
// names the server for its credential, its tools and its diagnostics.
|
|
168
|
+
const url = new URL(endpointOf(input.server, initial));
|
|
169
|
+
const fetchImpl = authorizedFetch(input.server, url, authorize, input.policy ?? {});
|
|
25
170
|
const transport = input.server.type === "sse"
|
|
26
171
|
? new SSEClientTransport(url, { fetch: fetchImpl })
|
|
27
172
|
: new StreamableHTTPClientTransport(url, { fetch: fetchImpl });
|
|
28
173
|
const client = createClient();
|
|
29
|
-
await
|
|
30
|
-
|
|
174
|
+
const { instructions, serverInfo } = await tracked(false, async (tracker) => {
|
|
175
|
+
try {
|
|
176
|
+
await client.connect(transport, input.signal ? { signal: input.signal } : undefined);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
// Nothing holds a connection that never opened: end whatever its transport started.
|
|
180
|
+
void client.close().catch(() => undefined);
|
|
181
|
+
throw scrubError(error, tracker);
|
|
182
|
+
}
|
|
183
|
+
const info = client.getServerVersion();
|
|
184
|
+
return {
|
|
185
|
+
instructions: serverInstructions(client.getInstructions(), [...tracker.secrets]),
|
|
186
|
+
serverInfo: info ? scrubbed({ ...info }, tracker) : undefined,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
client: sdkClient(client, input.server.name),
|
|
191
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
192
|
+
...(serverInfo === undefined ? {} : { serverInfo }),
|
|
193
|
+
close: () => client.close(),
|
|
194
|
+
};
|
|
31
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* What a server's `initialize` said to do with its tools, for the note on its deferred tools
|
|
198
|
+
* (R2b C10): cut to `SERVER_INSTRUCTIONS_MAX_CHARS`, without a credential value sent (C8).
|
|
199
|
+
*/
|
|
200
|
+
export function serverInstructions(text, secrets) {
|
|
201
|
+
if (typeof text !== "string" || text.trim().length === 0)
|
|
202
|
+
return undefined;
|
|
203
|
+
return scrubValues(text.trim().slice(0, SERVER_INSTRUCTIONS_MAX_CHARS), secrets).value;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Lists a server's tools under the names the model knows them by (R2b C6): `server__tool` where
|
|
207
|
+
* that is a name every provider accepts, else `normalizeToolName`'s, listed in `renamed`. A name
|
|
208
|
+
* taken by a declared tool or an earlier server's tool omits the tool; a renamed tool that
|
|
209
|
+
* collides gets the hash suffix first.
|
|
210
|
+
*/
|
|
32
211
|
export async function listMcpTools(client, input) {
|
|
33
|
-
const
|
|
34
|
-
const omitted = [];
|
|
212
|
+
const listed = [];
|
|
35
213
|
let cursor;
|
|
36
214
|
for (let page = 0; page < 100; page += 1) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
continue;
|
|
41
|
-
const name = modelToolName(input.serverName, tool.name);
|
|
42
|
-
const inputSchema = usableSchema(tool.inputSchema);
|
|
43
|
-
if (!inputSchema || input.taken.has(name)) {
|
|
44
|
-
omitted.push(name);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
const outputSchema = tool.outputSchema
|
|
48
|
-
? usableSchema(tool.outputSchema)
|
|
49
|
-
: undefined;
|
|
50
|
-
input.taken.add(name);
|
|
51
|
-
tools.push({
|
|
52
|
-
capabilityId: input.capabilityId,
|
|
53
|
-
serverName: input.serverName,
|
|
54
|
-
serverToolName: tool.name,
|
|
55
|
-
name,
|
|
56
|
-
...(typeof tool.description === "string" && tool.description.length > 0
|
|
57
|
-
? { description: tool.description }
|
|
58
|
-
: {}),
|
|
59
|
-
inputSchema,
|
|
60
|
-
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
cursor = listed.nextCursor;
|
|
215
|
+
const answer = await client.listTools(cursor ? { cursor } : undefined, input.signal ? { signal: input.signal } : undefined);
|
|
216
|
+
listed.push(...answer.tools);
|
|
217
|
+
cursor = answer.nextCursor;
|
|
64
218
|
if (!cursor)
|
|
65
219
|
break;
|
|
66
220
|
}
|
|
67
|
-
|
|
221
|
+
const server = input.server;
|
|
222
|
+
const listedNames = new Set(listed.map((tool) => tool.name));
|
|
223
|
+
// Servers change their lists, so a key naming no tool is a diagnostic, not a failure.
|
|
224
|
+
const unknownTools = Object.keys(server?.tools ?? {}).filter((key) => key !== MCP_TOOL_DEFAULTS && !listedNames.has(key));
|
|
225
|
+
let disabled = 0;
|
|
226
|
+
const omitted = [];
|
|
227
|
+
const candidates = [];
|
|
228
|
+
for (const tool of listed) {
|
|
229
|
+
if (typeof tool.name !== "string" || tool.name.length === 0)
|
|
230
|
+
continue;
|
|
231
|
+
if (server && !mcpToolSettings(server, tool.name).enabled) {
|
|
232
|
+
disabled += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const inputSchema = usableSchema(tool.inputSchema);
|
|
236
|
+
if (inputSchema)
|
|
237
|
+
candidates.push({ tool, inputSchema });
|
|
238
|
+
else
|
|
239
|
+
omitted.push(normalizeToolName(input.serverName, tool.name));
|
|
240
|
+
}
|
|
241
|
+
// Names that need no change first, so a renamed tool never takes the name of another of the
|
|
242
|
+
// server's tools.
|
|
243
|
+
const names = new Map();
|
|
244
|
+
const renamed = [];
|
|
245
|
+
for (const candidate of candidates) {
|
|
246
|
+
const name = normalizeToolName(input.serverName, candidate.tool.name);
|
|
247
|
+
if (name !== `${input.serverName}__${candidate.tool.name}`)
|
|
248
|
+
continue;
|
|
249
|
+
if (input.taken.has(name))
|
|
250
|
+
omitted.push(name);
|
|
251
|
+
else {
|
|
252
|
+
input.taken.add(name);
|
|
253
|
+
names.set(candidate, name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const candidate of candidates) {
|
|
257
|
+
const raw = `${input.serverName}__${candidate.tool.name}`;
|
|
258
|
+
let name = normalizeToolName(input.serverName, candidate.tool.name);
|
|
259
|
+
if (name === raw)
|
|
260
|
+
continue;
|
|
261
|
+
if (input.taken.has(name))
|
|
262
|
+
name = normalizeToolName(input.serverName, candidate.tool.name, { suffixed: true });
|
|
263
|
+
if (input.taken.has(name)) {
|
|
264
|
+
omitted.push(name);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
input.taken.add(name);
|
|
268
|
+
names.set(candidate, name);
|
|
269
|
+
renamed.push({ serverToolName: candidate.tool.name, name });
|
|
270
|
+
}
|
|
271
|
+
const tools = [];
|
|
272
|
+
for (const candidate of candidates) {
|
|
273
|
+
const name = names.get(candidate);
|
|
274
|
+
if (name === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
const { tool, inputSchema } = candidate;
|
|
277
|
+
const outputSchema = tool.outputSchema ? usableSchema(tool.outputSchema) : undefined;
|
|
278
|
+
const annotations = retryHints(tool.annotations);
|
|
279
|
+
tools.push({
|
|
280
|
+
capabilityId: input.capabilityId,
|
|
281
|
+
serverName: input.serverName,
|
|
282
|
+
serverToolName: tool.name,
|
|
283
|
+
name,
|
|
284
|
+
...(typeof tool.description === "string" && tool.description.length > 0
|
|
285
|
+
? { description: tool.description }
|
|
286
|
+
: {}),
|
|
287
|
+
inputSchema,
|
|
288
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
289
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return { tools, omitted, renamed, disabled, unknownTools };
|
|
68
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* One tool call and what it came to. Rejects with `McpConnectionStale` when the connection must
|
|
296
|
+
* be opened again (the caller retries once), and with any error that leaves the call's fate
|
|
297
|
+
* unknown.
|
|
298
|
+
*/
|
|
69
299
|
export async function callMcpTool(client, serverToolName, args, options = {}) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
:
|
|
75
|
-
|
|
300
|
+
let answer;
|
|
301
|
+
try {
|
|
302
|
+
answer = await client.callTool({
|
|
303
|
+
name: serverToolName,
|
|
304
|
+
arguments: args && typeof args === "object" && !Array.isArray(args)
|
|
305
|
+
? args
|
|
306
|
+
: {},
|
|
307
|
+
}, {
|
|
308
|
+
...(options.key === undefined ? {} : { key: options.key }),
|
|
309
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (!(error instanceof McpCallFailed))
|
|
314
|
+
throw error;
|
|
315
|
+
// The connection's to handle: its holder opens it again and sends the call once more.
|
|
316
|
+
if (error instanceof McpConnectionStale)
|
|
317
|
+
throw error;
|
|
318
|
+
// Sent, then lost: the tool may have run. Calling it again is the model's choice only for a
|
|
319
|
+
// tool the server says is safe to repeat; any other waits for an operator.
|
|
320
|
+
if (error.code === "mcp.lost" && !options.retrySafe)
|
|
321
|
+
throw new Error(error.message);
|
|
322
|
+
// Otherwise the outcome is known: the model sees it.
|
|
323
|
+
return error.outcome();
|
|
324
|
+
}
|
|
325
|
+
const { result } = answer;
|
|
326
|
+
const redacted = answer.redacted ? { redacted: answer.redacted } : {};
|
|
76
327
|
if ("isError" in result && result.isError) {
|
|
77
328
|
return {
|
|
78
329
|
kind: "failed",
|
|
79
330
|
code: "mcp.tool",
|
|
80
331
|
message: textContent(result.content) || "MCP tool failed",
|
|
332
|
+
...redacted,
|
|
81
333
|
};
|
|
82
334
|
}
|
|
83
335
|
if ("structuredContent" in result && result.structuredContent !== undefined)
|
|
84
|
-
return { kind: "completed", output: result.structuredContent };
|
|
85
|
-
|
|
336
|
+
return { kind: "completed", output: result.structuredContent, ...redacted };
|
|
337
|
+
// Text alone is the text. With an image, audio or a resource in it, every part stays, and core
|
|
338
|
+
// stores the bytes as artifacts before anything records them (R2b C11, `tenant/tool-results.ts`).
|
|
339
|
+
const text = "content" in result && onlyText(result.content) ? textContent(result.content) : undefined;
|
|
86
340
|
if (text !== undefined)
|
|
87
|
-
return { kind: "completed", output: text };
|
|
341
|
+
return { kind: "completed", output: text, ...redacted };
|
|
88
342
|
return {
|
|
89
343
|
kind: "completed",
|
|
90
344
|
output: "content" in result ? result.content : result,
|
|
345
|
+
...redacted,
|
|
91
346
|
};
|
|
92
347
|
}
|
|
348
|
+
function onlyText(content) {
|
|
349
|
+
return (Array.isArray(content) &&
|
|
350
|
+
content.every((part) => !!part && typeof part === "object" && part.type === "text"));
|
|
351
|
+
}
|
|
93
352
|
export function diagnosticFromError(capabilityId, serverName, error) {
|
|
94
353
|
const credentialIds = error && typeof error === "object" && "credentialIds" in error
|
|
95
354
|
? error.credentialIds
|
|
@@ -107,7 +366,8 @@ export function diagnosticFromError(capabilityId, serverName, error) {
|
|
|
107
366
|
const code = error && typeof error === "object" && "code" in error
|
|
108
367
|
? error.code
|
|
109
368
|
: undefined;
|
|
110
|
-
const unauthorized =
|
|
369
|
+
const unauthorized = error instanceof CredentialRejected ||
|
|
370
|
+
code === 401 ||
|
|
111
371
|
(error instanceof Error && /\b401\b/.test(error.message));
|
|
112
372
|
return {
|
|
113
373
|
capabilityId,
|
|
@@ -123,11 +383,21 @@ export function diagnosticFromError(capabilityId, serverName, error) {
|
|
|
123
383
|
function createClient() {
|
|
124
384
|
return new Client({ name: "nylorun-runtime", version: "0.6.0" }, { capabilities: {} });
|
|
125
385
|
}
|
|
126
|
-
|
|
386
|
+
/** Where `server`'s requests go: the credential's `via`, else the URL the manifest names. */
|
|
387
|
+
function endpointOf(server, result) {
|
|
388
|
+
return result.status === "authorized" && result.via !== undefined ? result.via : server.url;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* The transport's fetch: each request to `endpoint`'s origin carries the credential read for it
|
|
392
|
+
* now. A `401` answer is `CredentialRejected`, and a POST's other error statuses are
|
|
393
|
+
* `McpCallFailed`. Under a tracked operation it records the credential values it sends, whether
|
|
394
|
+
* a POST was sent, and a tool call's answer lost after it was.
|
|
395
|
+
*/
|
|
396
|
+
function authorizedFetch(server, endpoint, authorize, policy) {
|
|
127
397
|
const send = guardedFetch(policy, { stream: true });
|
|
128
398
|
return async (url, init) => {
|
|
129
399
|
const target = typeof url === "string" ? url : url.href;
|
|
130
|
-
if (!sameOrigin(target,
|
|
400
|
+
if (!sameOrigin(target, endpoint.href))
|
|
131
401
|
throw new Error("MCP request origin does not match the declared server");
|
|
132
402
|
const result = await authorize(server.url);
|
|
133
403
|
if (result.status === "refused") {
|
|
@@ -136,17 +406,230 @@ function authorizedFetch(server, authorize, policy) {
|
|
|
136
406
|
result.credentialIds;
|
|
137
407
|
throw error;
|
|
138
408
|
}
|
|
409
|
+
// A credential whose `via` changed since the connection opened: never send it elsewhere.
|
|
410
|
+
if (!sameOrigin(endpointOf(server, result), endpoint.href))
|
|
411
|
+
throw new McpConnectionStale(server.name, "is now reached at another address (its credential's via changed)");
|
|
139
412
|
const headers = new Headers();
|
|
140
413
|
for (const [key, value] of Object.entries(server.headers ?? {}))
|
|
141
414
|
headers.set(key, value);
|
|
142
415
|
new Headers(init?.headers).forEach((value, key) => headers.set(key, value));
|
|
416
|
+
// Credential headers, the identity header included, replace manifest headers of the same name.
|
|
143
417
|
if (result.status === "authorized")
|
|
144
418
|
for (const [key, value] of Object.entries(result.headers))
|
|
145
419
|
headers.set(key, value);
|
|
146
|
-
|
|
147
|
-
|
|
420
|
+
const tracker = tracking.getStore();
|
|
421
|
+
if (tracker && result.status === "authorized")
|
|
422
|
+
for (const secret of credentialSecrets(result.headers, result.identity))
|
|
423
|
+
tracker.secrets.add(secret);
|
|
424
|
+
const post = (init?.method ?? "GET").toUpperCase() === "POST";
|
|
425
|
+
let response;
|
|
426
|
+
try {
|
|
427
|
+
// No redirects, and the address checked on what is connected to (`tenant/outbound.ts`).
|
|
428
|
+
response = await send(target, { ...init, headers });
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (tracker && post && error instanceof OutboundFailed && error.sent)
|
|
432
|
+
tracker.sent = true;
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
if (tracker && post)
|
|
436
|
+
tracker.sent = true;
|
|
437
|
+
if (response.status === 401) {
|
|
438
|
+
await response.body?.cancel().catch(() => undefined);
|
|
439
|
+
throw new CredentialRejected(server.name, result.status === "authorized" ? result.vault : undefined, response.headers.get("www-authenticate") ?? undefined);
|
|
440
|
+
}
|
|
441
|
+
// The server ended the connection's session: the tool never saw the request.
|
|
442
|
+
if (post && response.status === 404 && headers.has("mcp-session-id")) {
|
|
443
|
+
await response.body?.cancel().catch(() => undefined);
|
|
444
|
+
throw new McpConnectionStale(server.name, "ended the connection's session (HTTP 404)");
|
|
445
|
+
}
|
|
446
|
+
// A GET's error status (405: no event stream) is the SDK's to read.
|
|
447
|
+
if (post && !response.ok)
|
|
448
|
+
throw statusFailure(server.name, response.status, await bodyText(response));
|
|
449
|
+
return tracker?.calling && post && response.body ? watched(response, tracker) : response;
|
|
148
450
|
};
|
|
149
451
|
}
|
|
452
|
+
/** Error statuses after which the same request may work. */
|
|
453
|
+
const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
|
|
454
|
+
/** A POST the server answered with an error status: its JSON-RPC error, or the status. */
|
|
455
|
+
function statusFailure(server, status, body) {
|
|
456
|
+
const rpc = rpcErrorOf(body);
|
|
457
|
+
if (status !== 403 && rpc)
|
|
458
|
+
return rpcFailure(server, `MCP error ${rpc.code}: ${rpc.message}`);
|
|
459
|
+
const text = body.length > MCP_ERROR_BODY_CHARS ? `${body.slice(0, MCP_ERROR_BODY_CHARS)}…` : body;
|
|
460
|
+
return new McpCallFailed({
|
|
461
|
+
code: status === 403 ? "mcp.forbidden" : "mcp.status",
|
|
462
|
+
message: `The MCP server '${server}' answered HTTP ${status}${text ? `: ${text}` : ""}`,
|
|
463
|
+
sent: true,
|
|
464
|
+
retryable: RETRYABLE_STATUSES.has(status),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function rpcFailure(server, message) {
|
|
468
|
+
return new McpCallFailed({
|
|
469
|
+
code: "mcp.error",
|
|
470
|
+
message: `The MCP server '${server}' answered an error: ${message.slice(0, MCP_ERROR_BODY_CHARS)}`,
|
|
471
|
+
sent: true,
|
|
472
|
+
retryable: false,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function unreachableFailure(server, detail) {
|
|
476
|
+
return new McpCallFailed({
|
|
477
|
+
code: "mcp.unreachable",
|
|
478
|
+
message: `The MCP server '${server}' could not be reached, and the call was not sent: ${detail.slice(0, MCP_ERROR_BODY_CHARS)}`,
|
|
479
|
+
sent: false,
|
|
480
|
+
retryable: true,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
function lostFailure(server, detail) {
|
|
484
|
+
return new McpCallFailed({
|
|
485
|
+
code: "mcp.lost",
|
|
486
|
+
message: `The connection to the MCP server '${server}' was lost after the call was sent, so it may have run: ${detail.slice(0, MCP_ERROR_BODY_CHARS)}`,
|
|
487
|
+
sent: true,
|
|
488
|
+
retryable: true,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
function tooLargeFailure(server) {
|
|
492
|
+
return new McpCallFailed({
|
|
493
|
+
code: "mcp.too-large",
|
|
494
|
+
message: `The MCP server '${server}' answered more than ${MCP_RESULT_MAX_BYTES} bytes, the most a tool result may hold, so the answer was not kept`,
|
|
495
|
+
sent: true,
|
|
496
|
+
retryable: false,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/** What a tool call's failure means (R2b C7). */
|
|
500
|
+
function failureOf(server, error, tracker) {
|
|
501
|
+
if (error instanceof McpCallFailed)
|
|
502
|
+
return error;
|
|
503
|
+
if (tracker.tooLarge)
|
|
504
|
+
return tooLargeFailure(server);
|
|
505
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
506
|
+
if (tracker.lost !== undefined)
|
|
507
|
+
return lostFailure(server, tracker.lost);
|
|
508
|
+
if (error instanceof OutboundRefused)
|
|
509
|
+
return unreachableFailure(server, detail);
|
|
510
|
+
if (error instanceof OutboundFailed)
|
|
511
|
+
return error.sent ? lostFailure(server, detail) : unreachableFailure(server, detail);
|
|
512
|
+
if (error instanceof McpError) {
|
|
513
|
+
// Sent, and no answer came in time.
|
|
514
|
+
if (error.code === ErrorCode.RequestTimeout)
|
|
515
|
+
return lostFailure(server, detail);
|
|
516
|
+
if (error.code === ErrorCode.ConnectionClosed)
|
|
517
|
+
return tracker.sent ? lostFailure(server, detail) : unreachableFailure(server, detail);
|
|
518
|
+
return rpcFailure(server, detail);
|
|
519
|
+
}
|
|
520
|
+
// An answer that could not be read leaves the call's fate unknown, as a lost one does.
|
|
521
|
+
return tracker.sent ? lostFailure(server, detail) : unreachableFailure(server, detail);
|
|
522
|
+
}
|
|
523
|
+
/** `value` with the operation's credential values replaced (R2b C8). */
|
|
524
|
+
function scrubbed(value, tracker) {
|
|
525
|
+
return tracker.secrets.size === 0 ? value : scrub(value, [...tracker.secrets], { valuesOnly: true });
|
|
526
|
+
}
|
|
527
|
+
/** `error` with no credential value in its message (R2b C8). */
|
|
528
|
+
function scrubError(error, tracker) {
|
|
529
|
+
if (tracker.secrets.size === 0)
|
|
530
|
+
return error;
|
|
531
|
+
if (error instanceof McpCallFailed) {
|
|
532
|
+
const message = scrubValues(error.failure.message, [...tracker.secrets]);
|
|
533
|
+
if (message.redacted === 0)
|
|
534
|
+
return error;
|
|
535
|
+
return new McpCallFailed({
|
|
536
|
+
...error.failure,
|
|
537
|
+
message: message.value,
|
|
538
|
+
redacted: (error.failure.redacted ?? 0) + message.redacted,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (error instanceof Error)
|
|
542
|
+
error.message = scrubbed(error.message, tracker);
|
|
543
|
+
return error;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* A tool call's answer whose body reports its loss (R2b C7): a stream that breaks after the
|
|
547
|
+
* request was sent ends the call as lost, now, instead of at the SDK's request timeout. One that
|
|
548
|
+
* passes `MCP_RESULT_MAX_BYTES` ends it as `mcp.too-large` (C11), before more of it is read.
|
|
549
|
+
*/
|
|
550
|
+
function watched(response, tracker) {
|
|
551
|
+
const source = response.body.getReader();
|
|
552
|
+
let received = 0;
|
|
553
|
+
const body = new ReadableStream({
|
|
554
|
+
async pull(controller) {
|
|
555
|
+
let next;
|
|
556
|
+
try {
|
|
557
|
+
next = await source.read();
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (!tracker.settled && tracker.lost === undefined && !tracker.tooLarge) {
|
|
561
|
+
tracker.lost = error instanceof Error ? error.message : String(error);
|
|
562
|
+
tracker.calling?.abort(new Error(tracker.lost));
|
|
563
|
+
}
|
|
564
|
+
controller.error(error);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (next.done)
|
|
568
|
+
return controller.close();
|
|
569
|
+
received += next.value.byteLength;
|
|
570
|
+
if (received > MCP_RESULT_MAX_BYTES) {
|
|
571
|
+
tracker.tooLarge = true;
|
|
572
|
+
const error = new Error(`The answer passed ${MCP_RESULT_MAX_BYTES} bytes`);
|
|
573
|
+
tracker.calling?.abort(error);
|
|
574
|
+
controller.error(error);
|
|
575
|
+
void source.cancel(error).catch(() => undefined);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
controller.enqueue(next.value);
|
|
579
|
+
},
|
|
580
|
+
cancel: (reason) => source.cancel(reason),
|
|
581
|
+
});
|
|
582
|
+
return new Response(body, {
|
|
583
|
+
status: response.status,
|
|
584
|
+
statusText: response.statusText,
|
|
585
|
+
headers: response.headers,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** The start of an error answer's body. */
|
|
589
|
+
async function bodyText(response) {
|
|
590
|
+
if (!response.body)
|
|
591
|
+
return "";
|
|
592
|
+
const reader = response.body.getReader();
|
|
593
|
+
const chunks = [];
|
|
594
|
+
let size = 0;
|
|
595
|
+
try {
|
|
596
|
+
while (size < 4 * MCP_ERROR_BODY_CHARS) {
|
|
597
|
+
const next = await reader.read();
|
|
598
|
+
if (next.done)
|
|
599
|
+
break;
|
|
600
|
+
chunks.push(next.value);
|
|
601
|
+
size += next.value.byteLength;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// What arrived is enough to say why.
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
void reader.cancel().catch(() => undefined);
|
|
609
|
+
}
|
|
610
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
611
|
+
}
|
|
612
|
+
/** A JSON-RPC error answer's code and message. */
|
|
613
|
+
function rpcErrorOf(body) {
|
|
614
|
+
try {
|
|
615
|
+
const parsed = JSON.parse(body);
|
|
616
|
+
const error = parsed?.error;
|
|
617
|
+
if (error && typeof error.code === "number" && typeof error.message === "string")
|
|
618
|
+
return { code: error.code, message: error.message };
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// Not JSON: an HTTP error status.
|
|
622
|
+
}
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
/** The hints that calling a tool again is safe, when the server gives any. */
|
|
626
|
+
function retryHints(annotations) {
|
|
627
|
+
const readOnly = annotations?.readOnlyHint === true;
|
|
628
|
+
const idempotent = annotations?.idempotentHint === true;
|
|
629
|
+
if (!readOnly && !idempotent)
|
|
630
|
+
return undefined;
|
|
631
|
+
return { ...(readOnly ? { readOnlyHint: true } : {}), ...(idempotent ? { idempotentHint: true } : {}) };
|
|
632
|
+
}
|
|
150
633
|
function sameOrigin(requestUrl, serverUrl) {
|
|
151
634
|
try {
|
|
152
635
|
return new URL(requestUrl).origin === new URL(serverUrl).origin;
|