@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +22 -13
  3. package/dist/adapters/streams/s2.js +5 -11
  4. package/dist/api/components.d.ts +326 -205
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/define.d.ts +3 -2
  8. package/dist/api/http/define.js +10 -11
  9. package/dist/api/http/routes/mcp.d.ts +9 -0
  10. package/dist/api/http/routes/mcp.js +32 -0
  11. package/dist/api/http/routes/vaults.d.ts +1 -9
  12. package/dist/api/http/routes/vaults.js +3 -103
  13. package/dist/api/openapi.js +37 -6
  14. package/dist/artifacts/media-types.d.ts +2 -0
  15. package/dist/artifacts/media-types.js +5 -0
  16. package/dist/execution/types.d.ts +1 -1
  17. package/dist/gates/http-tool.d.ts +7 -0
  18. package/dist/gates/http-tool.js +34 -3
  19. package/dist/gates/mcp-handler.d.ts +4 -1
  20. package/dist/gates/mcp-handler.js +64 -8
  21. package/dist/gates/tenant-vaults.d.ts +9 -15
  22. package/dist/gates/tenant-vaults.js +9 -17
  23. package/dist/gates/tool-client.js +36 -17
  24. package/dist/gates/tool-contract.d.ts +19 -3
  25. package/dist/gates/tool-contract.js +9 -3
  26. package/dist/gates/tool-gate.d.ts +2 -2
  27. package/dist/harness/calls.d.ts +2 -0
  28. package/dist/harness/calls.js +13 -3
  29. package/dist/harness-api/record.d.ts +7 -6
  30. package/dist/harness-api/record.js +99 -17
  31. package/dist/harness-api/server.js +35 -27
  32. package/dist/harness-api/start.js +2 -1
  33. package/dist/host/app.d.ts +3 -0
  34. package/dist/host/app.js +33 -4
  35. package/dist/host/config-for.d.ts +0 -2
  36. package/dist/host/config-for.js +0 -1
  37. package/dist/host/create-host.d.ts +6 -0
  38. package/dist/host/create-host.js +1 -0
  39. package/dist/host/gates.js +0 -1
  40. package/dist/host/main.js +24 -7
  41. package/dist/host/stack-config.d.ts +5 -8
  42. package/dist/host/stack-config.js +9 -20
  43. package/dist/index.d.ts +0 -1
  44. package/dist/infra/index.d.ts +2 -1
  45. package/dist/infra/index.js +0 -1
  46. package/dist/infra/readiness.d.ts +6 -6
  47. package/dist/infra/readiness.js +2 -5
  48. package/dist/keys/client.js +1 -9
  49. package/dist/keys/keys.d.ts +11 -13
  50. package/dist/keys/keys.js +5 -7
  51. package/dist/management-openapi.json +671 -378
  52. package/dist/mcp/connect.d.ts +159 -13
  53. package/dist/mcp/connect.js +538 -55
  54. package/dist/mcp/pool.d.ts +11 -2
  55. package/dist/mcp/pool.js +74 -27
  56. package/dist/mcp/preview.d.ts +35 -0
  57. package/dist/mcp/preview.js +215 -0
  58. package/dist/mcp/snapshot.d.ts +79 -5
  59. package/dist/mcp/snapshot.js +145 -27
  60. package/dist/model/catalog.d.ts +14 -0
  61. package/dist/model/catalog.js +14 -1
  62. package/dist/model/pi-model.js +36 -2
  63. package/dist/openapi.json +644 -266
  64. package/dist/redact.d.ts +19 -1
  65. package/dist/redact.js +42 -13
  66. package/dist/sandbox/session-sandbox.d.ts +7 -0
  67. package/dist/sandbox/session-sandbox.js +89 -28
  68. package/dist/store/postgres/connect.d.ts +10 -0
  69. package/dist/store/postgres/connect.js +30 -3
  70. package/dist/store/postgres/control.d.ts +14 -0
  71. package/dist/store/postgres/control.js +187 -0
  72. package/dist/store/postgres/drizzle/0015_control_signals.sql +11 -0
  73. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  74. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  75. package/dist/store/postgres/schema.d.ts +122 -261
  76. package/dist/store/postgres/schema.js +22 -36
  77. package/dist/store/postgres/store.js +16 -18
  78. package/dist/store/postgres/tenant.d.ts +14 -0
  79. package/dist/store/postgres/tenant.js +22 -2
  80. package/dist/store/types.d.ts +42 -8
  81. package/dist/streams/types.d.ts +6 -29
  82. package/dist/streams/types.js +5 -4
  83. package/dist/tenant/artifact-tool.d.ts +9 -1
  84. package/dist/tenant/artifact-tool.js +113 -9
  85. package/dist/tenant/auth.d.ts +16 -6
  86. package/dist/tenant/auth.js +41 -17
  87. package/dist/tenant/commands.js +10 -6
  88. package/dist/tenant/context.d.ts +9 -6
  89. package/dist/tenant/control.d.ts +26 -0
  90. package/dist/tenant/control.js +35 -0
  91. package/dist/tenant/effects.d.ts +2 -2
  92. package/dist/tenant/effects.js +20 -5
  93. package/dist/tenant/ephemeral.d.ts +0 -6
  94. package/dist/tenant/ephemeral.js +1 -1
  95. package/dist/tenant/http.d.ts +4 -2
  96. package/dist/tenant/http.js +4 -2
  97. package/dist/tenant/identity-file.d.ts +16 -12
  98. package/dist/tenant/identity-file.js +41 -29
  99. package/dist/tenant/issuers.d.ts +4 -3
  100. package/dist/tenant/issuers.js +22 -16
  101. package/dist/tenant/outbound.d.ts +14 -5
  102. package/dist/tenant/outbound.js +21 -9
  103. package/dist/tenant/reset.d.ts +2 -2
  104. package/dist/tenant/reset.js +8 -2
  105. package/dist/tenant/resource-server.d.ts +56 -0
  106. package/dist/tenant/resource-server.js +62 -0
  107. package/dist/tenant/runtime.d.ts +1 -1
  108. package/dist/tenant/runtime.js +16 -15
  109. package/dist/tenant/scheduler.d.ts +1 -1
  110. package/dist/tenant/scheduler.js +1 -1
  111. package/dist/tenant/sessions.js +20 -3
  112. package/dist/tenant/store-pg.js +9 -0
  113. package/dist/tenant/streams.d.ts +16 -22
  114. package/dist/tenant/streams.js +7 -87
  115. package/dist/tenant/sweep.js +9 -1
  116. package/dist/tenant/tool-results.d.ts +57 -0
  117. package/dist/tenant/tool-results.js +298 -0
  118. package/dist/tenant/tool-search.d.ts +43 -0
  119. package/dist/tenant/tool-search.js +100 -0
  120. package/dist/tenant/transcript.d.ts +108 -0
  121. package/dist/tenant/transcript.js +13 -0
  122. package/dist/tenant/types.d.ts +0 -14
  123. package/dist/vault/crypto.d.ts +0 -7
  124. package/dist/vault/crypto.js +0 -16
  125. package/dist/vault/headers.d.ts +17 -0
  126. package/dist/vault/headers.js +61 -0
  127. package/dist/vault/service.d.ts +11 -68
  128. package/dist/vault/service.js +83 -438
  129. package/dist/vault/sources.d.ts +20 -48
  130. package/dist/vault/sources.js +25 -189
  131. package/dist/version.d.ts +1 -1
  132. package/dist/version.js +1 -1
  133. package/package.json +3 -3
  134. package/dist/streams/control.d.ts +0 -13
  135. package/dist/streams/control.js +0 -25
  136. package/dist/vault/oauth.d.ts +0 -55
  137. package/dist/vault/oauth.js +0 -186
@@ -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 { schemaFromJSON } from "@nylorun/core/define";
5
- import { guardedFetch } from "../tenant/outbound.js";
6
- import { modelToolName, } from "./snapshot.js";
7
- /** An SDK `Client` as an `McpClient`. */
8
- export function sdkClient(client) {
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) => client.listTools(params, options?.signal ? { signal: options.signal } : undefined),
11
- callTool: (params, options) => client.callTool(params, undefined, options?.signal ? { signal: options.signal } : undefined),
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
- const fetchImpl = authorizedFetch(input.server, authorize, input.policy ?? {});
24
- const url = new URL(input.server.url);
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 client.connect(transport);
30
- return { client: sdkClient(client), close: () => client.close() };
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 tools = [];
34
- const omitted = [];
212
+ const listed = [];
35
213
  let cursor;
36
214
  for (let page = 0; page < 100; page += 1) {
37
- const listed = await client.listTools(cursor ? { cursor } : undefined, input.signal ? { signal: input.signal } : undefined);
38
- for (const tool of listed.tools) {
39
- if (typeof tool.name !== "string" || tool.name.length === 0)
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
- return { tools, omitted };
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
- const result = await client.callTool({
71
- name: serverToolName,
72
- arguments: args && typeof args === "object" && !Array.isArray(args)
73
- ? args
74
- : {},
75
- }, options);
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
- const text = "content" in result ? textContent(result.content) : undefined;
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 = code === 401 ||
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
- function authorizedFetch(server, authorize, policy) {
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, server.url))
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
- // No redirects, and the address checked on what is connected to (`tenant/outbound.ts`).
147
- return send(target, { ...init, headers });
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;