@nylorun/runtime 0.20.0-beta → 0.21.1-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 (109) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +19 -12
  3. package/dist/adapters/execution/restate.js +26 -1
  4. package/dist/api/components.d.ts +155 -36
  5. package/dist/api/components.js +3 -3
  6. package/dist/api/http/app.js +2 -0
  7. package/dist/api/http/routes/mcp.d.ts +9 -0
  8. package/dist/api/http/routes/mcp.js +32 -0
  9. package/dist/api/http/routes/vaults.d.ts +1 -9
  10. package/dist/api/http/routes/vaults.js +3 -103
  11. package/dist/api/openapi.js +6 -3
  12. package/dist/artifacts/media-types.d.ts +2 -0
  13. package/dist/artifacts/media-types.js +5 -0
  14. package/dist/core/flow-host.d.ts +2 -3
  15. package/dist/core/flow-host.js +10 -7
  16. package/dist/execution/types.d.ts +13 -5
  17. package/dist/execution/types.js +13 -5
  18. package/dist/gates/http-tool.d.ts +7 -0
  19. package/dist/gates/http-tool.js +34 -3
  20. package/dist/gates/mcp-handler.d.ts +4 -1
  21. package/dist/gates/mcp-handler.js +64 -8
  22. package/dist/gates/tenant-vaults.d.ts +9 -15
  23. package/dist/gates/tenant-vaults.js +9 -17
  24. package/dist/gates/tool-client.js +36 -17
  25. package/dist/gates/tool-contract.d.ts +19 -3
  26. package/dist/gates/tool-contract.js +9 -3
  27. package/dist/gates/tool-gate.d.ts +2 -2
  28. package/dist/harness/calls.d.ts +2 -0
  29. package/dist/harness/calls.js +13 -3
  30. package/dist/harness-api/record.d.ts +7 -6
  31. package/dist/harness-api/record.js +99 -17
  32. package/dist/harness-api/server.js +35 -27
  33. package/dist/harness-api/start.js +2 -1
  34. package/dist/host/config-for.d.ts +0 -2
  35. package/dist/host/config-for.js +0 -1
  36. package/dist/host/create-host.d.ts +1 -1
  37. package/dist/host/create-host.js +2 -1
  38. package/dist/host/gates.js +0 -1
  39. package/dist/host/http.d.ts +0 -1
  40. package/dist/host/http.js +0 -6
  41. package/dist/host/main.js +13 -3
  42. package/dist/host/stack-config.d.ts +7 -8
  43. package/dist/host/stack-config.js +50 -92
  44. package/dist/index.d.ts +0 -1
  45. package/dist/keys/client.js +1 -9
  46. package/dist/keys/keys.d.ts +11 -13
  47. package/dist/keys/keys.js +5 -7
  48. package/dist/management-openapi.json +315 -345
  49. package/dist/mcp/connect.d.ts +159 -13
  50. package/dist/mcp/connect.js +538 -55
  51. package/dist/mcp/pool.d.ts +11 -2
  52. package/dist/mcp/pool.js +74 -27
  53. package/dist/mcp/preview.d.ts +35 -0
  54. package/dist/mcp/preview.js +215 -0
  55. package/dist/mcp/snapshot.d.ts +79 -5
  56. package/dist/mcp/snapshot.js +145 -27
  57. package/dist/model/catalog.d.ts +14 -0
  58. package/dist/model/catalog.js +14 -1
  59. package/dist/model/pi-model.js +36 -2
  60. package/dist/openapi.json +362 -123
  61. package/dist/redact.d.ts +19 -1
  62. package/dist/redact.js +42 -13
  63. package/dist/sandbox/session-sandbox.d.ts +7 -0
  64. package/dist/sandbox/session-sandbox.js +89 -28
  65. package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
  66. package/dist/store/postgres/drizzle/0017_wake_outbox.sql +9 -0
  67. package/dist/store/postgres/drizzle/meta/_journal.json +14 -0
  68. package/dist/store/postgres/schema.d.ts +105 -261
  69. package/dist/store/postgres/schema.js +18 -36
  70. package/dist/store/postgres/store.js +54 -21
  71. package/dist/store/postgres/tenant.d.ts +14 -0
  72. package/dist/store/postgres/tenant.js +22 -2
  73. package/dist/store/types.d.ts +48 -13
  74. package/dist/tenant/advance.js +1 -4
  75. package/dist/tenant/artifact-tool.d.ts +9 -1
  76. package/dist/tenant/artifact-tool.js +113 -9
  77. package/dist/tenant/commands.js +5 -5
  78. package/dist/tenant/context.d.ts +15 -13
  79. package/dist/tenant/effects.d.ts +2 -2
  80. package/dist/tenant/effects.js +20 -5
  81. package/dist/tenant/ephemeral.d.ts +0 -6
  82. package/dist/tenant/ephemeral.js +0 -1
  83. package/dist/tenant/outbound.d.ts +14 -5
  84. package/dist/tenant/outbound.js +21 -9
  85. package/dist/tenant/runtime.js +11 -15
  86. package/dist/tenant/sessions.js +20 -3
  87. package/dist/tenant/store-pg.js +9 -0
  88. package/dist/tenant/sweep.d.ts +14 -0
  89. package/dist/tenant/sweep.js +69 -10
  90. package/dist/tenant/tool-results.d.ts +57 -0
  91. package/dist/tenant/tool-results.js +298 -0
  92. package/dist/tenant/tool-search.d.ts +43 -0
  93. package/dist/tenant/tool-search.js +100 -0
  94. package/dist/tenant/transcript.d.ts +108 -0
  95. package/dist/tenant/transcript.js +13 -0
  96. package/dist/tenant/types.d.ts +0 -14
  97. package/dist/vault/crypto.d.ts +0 -7
  98. package/dist/vault/crypto.js +0 -16
  99. package/dist/vault/headers.d.ts +17 -0
  100. package/dist/vault/headers.js +61 -0
  101. package/dist/vault/service.d.ts +11 -68
  102. package/dist/vault/service.js +83 -438
  103. package/dist/vault/sources.d.ts +20 -48
  104. package/dist/vault/sources.js +25 -189
  105. package/dist/version.d.ts +1 -1
  106. package/dist/version.js +1 -1
  107. package/package.json +3 -3
  108. package/dist/vault/oauth.d.ts +0 -55
  109. package/dist/vault/oauth.js +0 -186
@@ -1,7 +1,7 @@
1
1
  import type { AgentManifest, McpServerManifest } from "@nylorun/core/define";
2
2
  import type { OutboundPolicy } from "../tenant/outbound.js";
3
3
  import type { AuthorizeResult } from "../vault/service.js";
4
- import { callMcpTool, openMcpServer, type LiveConnection } from "./connect.js";
4
+ import { openMcpServer, type LiveConnection, type McpToolOutcome } from "./connect.js";
5
5
  import { type McpDiagnostic, type McpSnapshot, type McpToolRecord } from "./snapshot.js";
6
6
  /** A connection unused this long is closed by the sweep; the next call opens it again. */
7
7
  export declare const DEFAULT_MCP_IDLE_MS: number;
@@ -58,8 +58,17 @@ export declare class McpPool {
58
58
  manifest: AgentManifest;
59
59
  /** The effect id: a gate runs the call once under it (F4.1). */
60
60
  effectId?: string;
61
+ /** The tool is read-only or idempotent: a lost answer is the model's to retry (R2b C7). */
62
+ retrySafe?: boolean;
61
63
  signal?: AbortSignal;
62
- }): Promise<Awaited<ReturnType<typeof callMcpTool>>>;
64
+ }): Promise<McpToolOutcome>;
65
+ /** The session's connection to the server, opened when there is none, or why it cannot be. */
66
+ private liveFor;
67
+ /**
68
+ * Takes a connection out of the pool, so the next call opens a new one. Calls still running on
69
+ * it finish first: the last one closes it.
70
+ */
71
+ private evict;
63
72
  /**
64
73
  * Closes connections unused for the idle timeout (the Tenant sweep). A session that calls
65
74
  * one again reconnects, as after a restart; this bounds the connections a Tenant keeps.
package/dist/mcp/pool.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { delegatesOf } from "@nylorun/core/define";
2
- import { callMcpTool, diagnosticFromError, listMcpTools, openMcpServer, } from "./connect.js";
2
+ import { callMcpTool, diagnosticFromError, listMcpTools, McpConnectionStale, openMcpServer, unreachable, } from "./connect.js";
3
3
  import { declaredToolNames, } from "./snapshot.js";
4
4
  /** A connection unused this long is closed by the sweep; the next call opens it again. */
5
5
  export const DEFAULT_MCP_IDLE_MS = 15 * 60_000;
@@ -21,6 +21,7 @@ export class McpPool {
21
21
  // Each agent names its own tools, so collisions are checked per agent.
22
22
  const taken = new Map();
23
23
  const tools = [];
24
+ const notes = [];
24
25
  const diagnostics = [];
25
26
  for (const declared of serversOf(input.manifest)) {
26
27
  input.signal?.throwIfAborted();
@@ -36,17 +37,31 @@ export class McpPool {
36
37
  capabilityId: declared.capabilityId,
37
38
  serverName: declared.server.name,
38
39
  taken: taken.get(declared.agentId),
40
+ server: declared.server,
39
41
  ...(input.signal ? { signal: input.signal } : {}),
40
42
  });
41
43
  tools.push(...listed.tools.map((tool) => owned(declared, tool)));
44
+ if (opened.connection.instructions)
45
+ notes.push(owned(declared, {
46
+ capabilityId: declared.capabilityId,
47
+ serverName: declared.server.name,
48
+ instructions: opened.connection.instructions,
49
+ }));
42
50
  diagnostics.push({
43
51
  ...ownerOf(declared),
44
52
  capabilityId: declared.capabilityId,
45
53
  serverName: declared.server.name,
46
54
  outcome: "connected",
47
- message: listed.omitted.length === 0
48
- ? "Connected"
49
- : `Connected. Omitted ${listed.omitted.join(", ")}`,
55
+ message: [
56
+ "Connected",
57
+ ...(listed.omitted.length === 0 ? [] : [`Omitted ${listed.omitted.join(", ")}`]),
58
+ ...(listed.unknownTools.length === 0
59
+ ? []
60
+ : [`Its tools settings name tools it does not list: ${listed.unknownTools.join(", ")}`]),
61
+ ].join(". "),
62
+ ...(listed.renamed.length === 0 ? {} : { renamed: listed.renamed }),
63
+ ...(listed.disabled === 0 ? {} : { disabled: listed.disabled }),
64
+ ...(listed.unknownTools.length === 0 ? {} : { unknownTools: listed.unknownTools }),
50
65
  });
51
66
  this.remember(input.sessionId, declared, opened.connection);
52
67
  }
@@ -60,6 +75,7 @@ export class McpPool {
60
75
  snapshotSchemaVersion: 1,
61
76
  manifestHash: input.manifestHash,
62
77
  mcpTools: tools,
78
+ ...(notes.length === 0 ? {} : { servers: notes }),
63
79
  },
64
80
  diagnostics,
65
81
  };
@@ -101,32 +117,62 @@ export class McpPool {
101
117
  }
102
118
  async call(input) {
103
119
  const key = this.liveKey(input.sessionId, input.agentId, input.capabilityId, input.serverName);
104
- let live = this.live.get(key);
105
- if (!live) {
106
- const declared = findServer(input.manifest, input.agentId, input.capabilityId, input.serverName);
107
- if (!declared)
108
- throw new Error(`MCP server '${input.serverName}' is not declared`);
109
- let opening = this.opening.get(key);
110
- if (!opening) {
111
- opening = this.connectDeclared(input, declared).finally(() => this.opening.delete(key));
112
- this.opening.set(key, opening);
120
+ for (let attempt = 0;; attempt += 1) {
121
+ const live = await this.liveFor(input, key);
122
+ // No connection, so no call was sent: the model sees why, a 401 included (R2b C1, C7).
123
+ if (!("connection" in live))
124
+ return live;
125
+ live.active += 1;
126
+ try {
127
+ return await callMcpTool(live.connection.client, input.serverToolName, input.args, {
128
+ ...(input.effectId === undefined ? {} : { key: input.effectId }),
129
+ ...(input.retrySafe ? { retrySafe: true } : {}),
130
+ ...(input.signal ? { signal: input.signal } : {}),
131
+ });
132
+ }
133
+ catch (error) {
134
+ if (!(error instanceof McpConnectionStale))
135
+ throw error;
136
+ // The server ended the connection's session, or its credential now sends it elsewhere,
137
+ // and the tool never saw the call: drop the connection and send it once more on a new one.
138
+ this.evict(key, live);
139
+ if (attempt > 0)
140
+ return error.outcome();
141
+ }
142
+ finally {
143
+ live.active -= 1;
144
+ live.lastUsedAt = this.now();
145
+ if (live.evicted && live.active === 0)
146
+ void live.connection.close().catch(() => { });
113
147
  }
114
- const opened = await opening;
115
- if (!opened.ok)
116
- throw new Error(opened.diagnostic.message);
117
- live = this.remember(input.sessionId, declared, opened.connection);
118
- }
119
- live.active += 1;
120
- try {
121
- return await callMcpTool(live.connection.client, input.serverToolName, input.args, {
122
- ...(input.effectId === undefined ? {} : { key: input.effectId }),
123
- ...(input.signal ? { signal: input.signal } : {}),
124
- });
125
148
  }
126
- finally {
127
- live.active -= 1;
128
- live.lastUsedAt = this.now();
149
+ }
150
+ /** The session's connection to the server, opened when there is none, or why it cannot be. */
151
+ async liveFor(input, key) {
152
+ const live = this.live.get(key);
153
+ if (live)
154
+ return live;
155
+ const declared = findServer(input.manifest, input.agentId, input.capabilityId, input.serverName);
156
+ if (!declared)
157
+ throw new Error(`MCP server '${input.serverName}' is not declared`);
158
+ let opening = this.opening.get(key);
159
+ if (!opening) {
160
+ opening = this.connectDeclared(input, declared).finally(() => this.opening.delete(key));
161
+ this.opening.set(key, opening);
129
162
  }
163
+ const opened = await opening;
164
+ if (!opened.ok)
165
+ return unreachable(input.serverName, opened.error).outcome();
166
+ return this.remember(input.sessionId, declared, opened.connection);
167
+ }
168
+ /**
169
+ * Takes a connection out of the pool, so the next call opens a new one. Calls still running on
170
+ * it finish first: the last one closes it.
171
+ */
172
+ evict(key, live) {
173
+ if (this.live.get(key) === live)
174
+ this.live.delete(key);
175
+ live.evicted = true;
130
176
  }
131
177
  /**
132
178
  * Closes connections unused for the idle timeout (the Tenant sweep). A session that calls
@@ -195,6 +241,7 @@ export class McpPool {
195
241
  catch (error) {
196
242
  return {
197
243
  ok: false,
244
+ error,
198
245
  diagnostic: owned(declared, diagnosticFromError(declared.capabilityId, declared.server.name, error)),
199
246
  };
200
247
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A remote MCP server's tools before an agent names it (R2b C12, `POST /v1/tenant/mcp/preview`,
3
+ * `nylorun mcp inspect`, Studio's Credentials page). It runs where plaintext credentials live:
4
+ * the keys service (`keys/keys.ts`, `previewMcp`), in the gateway or in process.
5
+ *
6
+ * One connection: `initialize` and `tools/list` with the installation vault's credential for the
7
+ * URL (its headers, and its `via`), under the Host's address policy, within 15 s; then it closes.
8
+ * It never calls a tool, and sends no identity header: no person asked. A server that answers
9
+ * `401` is `authRequired`, with its RFC 9728 protected-resource metadata when it publishes any,
10
+ * read under the same policy. No credential value sent is in the answer (`scrubValues`, C8).
11
+ */
12
+ import { type McpPreview, type McpPreviewRequest } from "@nylorun/core/contracts";
13
+ import { type OutboundPolicy } from "../tenant/outbound.js";
14
+ import type { VaultService } from "../vault/service.js";
15
+ import { openMcpServer } from "./connect.js";
16
+ /** How long a preview may take, `initialize` and every `tools/list` page together. */
17
+ export declare const MCP_PREVIEW_TIMEOUT_MS = 15000;
18
+ export interface McpPreviewOptions {
19
+ readonly vault: Pick<VaultService, "authorize" | "getVault" | "listVaults">;
20
+ /** The Host's address policy for developer URLs (`TenantConfig.delivery`). */
21
+ readonly policy: OutboundPolicy;
22
+ /** Default `MCP_PREVIEW_TIMEOUT_MS`. */
23
+ readonly timeoutMs?: number;
24
+ /** Tests replace how the server is opened. */
25
+ readonly open?: typeof openMcpServer;
26
+ }
27
+ /**
28
+ * The server name a preview makes model names with when the request gives none: the URL host's
29
+ * name before its top-level domain, without a leading `mcp`, `www` or `api` (`linear` for
30
+ * `mcp.linear.app`); an IP address or a one-label host whole. Characters outside
31
+ * `[A-Za-z0-9_-]` become `_`.
32
+ */
33
+ export declare function previewServerName(url: string): string;
34
+ /** Lists the server's tools as an agent's session would see them; never calls one. */
35
+ export declare function previewMcpServer(body: McpPreviewRequest, options: McpPreviewOptions): Promise<McpPreview>;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * A remote MCP server's tools before an agent names it (R2b C12, `POST /v1/tenant/mcp/preview`,
3
+ * `nylorun mcp inspect`, Studio's Credentials page). It runs where plaintext credentials live:
4
+ * the keys service (`keys/keys.ts`, `previewMcp`), in the gateway or in process.
5
+ *
6
+ * One connection: `initialize` and `tools/list` with the installation vault's credential for the
7
+ * URL (its headers, and its `via`), under the Host's address policy, within 15 s; then it closes.
8
+ * It never calls a tool, and sends no identity header: no person asked. A server that answers
9
+ * `401` is `authRequired`, with its RFC 9728 protected-resource metadata when it publishes any,
10
+ * read under the same policy. No credential value sent is in the answer (`scrubValues`, C8).
11
+ */
12
+ import { INSTALLATION_OWNER, McpPreviewRequestSchema, } from "@nylorun/core/contracts";
13
+ import { scrubValues } from "../redact.js";
14
+ import { HttpError } from "../tenant/http.js";
15
+ import { guardedFetch, OutboundRefused } from "../tenant/outbound.js";
16
+ import { VaultError } from "../vault/error.js";
17
+ import { credentialSecrets } from "../vault/headers.js";
18
+ import { CredentialRejected, listMcpTools, McpCallFailed, openMcpServer, } from "./connect.js";
19
+ /** How long a preview may take, `initialize` and every `tools/list` page together. */
20
+ export const MCP_PREVIEW_TIMEOUT_MS = 15_000;
21
+ /** The largest protected-resource metadata document read. */
22
+ const RESOURCE_METADATA_MAX_BYTES = 64 * 1024;
23
+ /** Host labels that name a service's role rather than the service. */
24
+ const GENERIC_LABELS = new Set(["mcp", "www", "api"]);
25
+ /**
26
+ * The server name a preview makes model names with when the request gives none: the URL host's
27
+ * name before its top-level domain, without a leading `mcp`, `www` or `api` (`linear` for
28
+ * `mcp.linear.app`); an IP address or a one-label host whole. Characters outside
29
+ * `[A-Za-z0-9_-]` become `_`.
30
+ */
31
+ export function previewServerName(url) {
32
+ const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
33
+ const labels = host.split(".").filter(Boolean);
34
+ const ip = /^[\d.]+$/.test(host) || host.includes(":");
35
+ let name = host;
36
+ if (!ip && labels.length > 1) {
37
+ const named = labels.slice(0, -1);
38
+ while (named.length > 1 && GENERIC_LABELS.has(named[0].toLowerCase()))
39
+ named.shift();
40
+ name = named.at(-1);
41
+ }
42
+ return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "server";
43
+ }
44
+ /** Lists the server's tools as an agent's session would see them; never calls one. */
45
+ export async function previewMcpServer(body, options) {
46
+ const request = McpPreviewRequestSchema.parse(body);
47
+ const type = request.type ?? "streamable-http";
48
+ const name = request.name ?? previewServerName(request.url);
49
+ const server = { name, type, url: request.url };
50
+ const authorized = await credentialFor(options.vault, request);
51
+ const secrets = authorized.status === "authorized" ? credentialSecrets(authorized.headers) : [];
52
+ const credentialSent = authorized.status === "authorized";
53
+ const base = { name, url: request.url, type, credentialSent };
54
+ const signal = AbortSignal.timeout(options.timeoutMs ?? MCP_PREVIEW_TIMEOUT_MS);
55
+ const open = options.open ?? openMcpServer;
56
+ let connection;
57
+ try {
58
+ connection = await open({
59
+ server,
60
+ // Read once: one audit row, and the same credential for every request.
61
+ authorize: async () => authorized,
62
+ policy: options.policy,
63
+ signal,
64
+ });
65
+ const pages = [];
66
+ const listing = {
67
+ listTools: async (params, requestOptions) => {
68
+ const page = await connection.client.listTools(params, requestOptions);
69
+ pages.push(page);
70
+ return page;
71
+ },
72
+ callTool: () => Promise.reject(new Error("A preview never calls a tool")),
73
+ };
74
+ const listed = await listMcpTools(listing, {
75
+ capabilityId: "preview",
76
+ serverName: name,
77
+ taken: new Set(),
78
+ signal,
79
+ });
80
+ const modelNames = new Map(listed.tools.map((tool) => [tool.serverToolName, tool.name]));
81
+ const tools = pages
82
+ .flatMap((page) => page.tools)
83
+ .filter((tool) => typeof tool.name === "string" && tool.name.length > 0)
84
+ .map((tool) => {
85
+ const modelName = modelNames.get(tool.name);
86
+ return {
87
+ serverToolName: tool.name,
88
+ ...(modelName === undefined ? {} : { modelName }),
89
+ ...(typeof tool.description === "string" && tool.description.length > 0
90
+ ? { description: tool.description }
91
+ : {}),
92
+ ...(isRecord(tool.annotations) && Object.keys(tool.annotations).length > 0
93
+ ? { annotations: { ...tool.annotations } }
94
+ : {}),
95
+ schemaBytes: Buffer.byteLength(JSON.stringify(tool.inputSchema ?? {})),
96
+ };
97
+ });
98
+ const preview = {
99
+ ...base,
100
+ ...(connection.serverInfo === undefined ? {} : { serverInfo: { ...connection.serverInfo } }),
101
+ ...(connection.instructions === undefined ? {} : { instructions: connection.instructions }),
102
+ tools,
103
+ renamed: listed.renamed.map(({ serverToolName, name: renamedTo }) => ({
104
+ serverToolName,
105
+ name: renamedTo,
106
+ })),
107
+ };
108
+ return scrubValues(preview, secrets).value;
109
+ }
110
+ catch (error) {
111
+ if (error instanceof CredentialRejected) {
112
+ const authRequired = await protectedResource(request.url, error.challenge, options.policy, signal);
113
+ return scrubValues({ ...base, tools: [], renamed: [], authRequired }, secrets).value;
114
+ }
115
+ throw failed(name, error, signal, secrets, options.timeoutMs ?? MCP_PREVIEW_TIMEOUT_MS);
116
+ }
117
+ finally {
118
+ await connection?.close().catch(() => undefined);
119
+ }
120
+ }
121
+ /**
122
+ * The installation vault credential for the URL: from `vaultId`, else from every installation
123
+ * vault. None is a preview without one; two or more without `vaultId` is a refusal that names
124
+ * them.
125
+ */
126
+ async function credentialFor(vault, request) {
127
+ let vaultIds;
128
+ if (request.vaultId !== undefined) {
129
+ const found = await vault.getVault(request.vaultId);
130
+ if (found.ownerUserId !== INSTALLATION_OWNER)
131
+ throw new VaultError(400, "A preview uses an installation vault's credential: no person asked for it");
132
+ vaultIds = [found.id];
133
+ }
134
+ else
135
+ vaultIds = (await vault.listVaults(undefined, { installation: true })).map((found) => found.id);
136
+ if (vaultIds.length === 0)
137
+ return { status: "unauthenticated", url: request.url, headers: {} };
138
+ const result = await vault.authorize({ vaultIds, credentialSelections: [], url: request.url });
139
+ if (result.status !== "refused")
140
+ return result;
141
+ throw new HttpError(409, result.reason === "ambiguous"
142
+ ? "Several installation vaults hold a credential for this URL: name one with vaultId"
143
+ : `The installation vault's credential for this URL cannot be used (${result.reason})`, { code: "request_rejected", details: { reason: result.reason, credentialIds: result.credentialIds } });
144
+ }
145
+ /** A preview that listed nothing: `mcp_preview_failed`, with the code a tool call would have had. */
146
+ function failed(server, error, signal, secrets, timeoutMs) {
147
+ if (error instanceof HttpError || error instanceof VaultError)
148
+ return error;
149
+ const failure = signal.aborted
150
+ ? { code: "mcp.unreachable", message: `The MCP server '${server}' did not answer within ${timeoutMs / 1000} s` }
151
+ : error instanceof McpCallFailed
152
+ ? { code: error.code, message: error.message }
153
+ : {
154
+ code: "mcp.unreachable",
155
+ message: refusedBy(error) !== undefined
156
+ ? `The MCP server '${server}' was not called: ${refusedBy(error).message}`
157
+ : `The MCP server '${server}' could not be listed: ${error instanceof Error ? error.message : String(error)}`,
158
+ };
159
+ const message = scrubValues(failure.message.slice(0, 2_000), secrets).value;
160
+ return new HttpError(502, message, { code: "mcp_preview_failed", details: { failure: failure.code } });
161
+ }
162
+ /** The Host's refusal of the address, when that is why the server was not reached. */
163
+ function refusedBy(error) {
164
+ if (error instanceof OutboundRefused)
165
+ return error;
166
+ return error instanceof Error && error.cause instanceof OutboundRefused ? error.cause : undefined;
167
+ }
168
+ /**
169
+ * What a `401` says about signing in (RFC 9728): the metadata its `WWW-Authenticate` names in
170
+ * `resource_metadata`, else the one at the URL's well-known path (with the URL's path, then at
171
+ * the origin). Absent fields when the server publishes none.
172
+ */
173
+ async function protectedResource(url, challenge, policy, signal) {
174
+ const named = challenge === undefined ? undefined : resourceMetadataParam(challenge);
175
+ const target = new URL(url);
176
+ const wellKnown = "/.well-known/oauth-protected-resource";
177
+ const path = target.pathname.replace(/\/$/, "");
178
+ const candidates = named
179
+ ? [named]
180
+ : [...(path ? [`${target.origin}${wellKnown}${path}`] : []), `${target.origin}${wellKnown}`];
181
+ const fetchMetadata = guardedFetch(policy, { maxResponseBytes: RESOURCE_METADATA_MAX_BYTES });
182
+ for (const candidate of candidates) {
183
+ let resolved;
184
+ try {
185
+ resolved = new URL(candidate, url).href;
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ try {
191
+ const response = await fetchMetadata(resolved, {
192
+ headers: { accept: "application/json" },
193
+ signal,
194
+ });
195
+ if (!response.ok)
196
+ continue;
197
+ const document = (await response.json());
198
+ if (isRecord(document))
199
+ return { resourceMetadataUrl: resolved, resourceMetadata: document };
200
+ }
201
+ catch {
202
+ // Unreachable, refused or not JSON: the next place, or no metadata.
203
+ }
204
+ }
205
+ return {};
206
+ }
207
+ /** `resource_metadata` of a `WWW-Authenticate` challenge (RFC 9728 §5.1). */
208
+ function resourceMetadataParam(challenge) {
209
+ const match = /resource_metadata\s*=\s*(?:"([^"]*)"|([^\s,]+))/i.exec(challenge);
210
+ const value = match?.[1] ?? match?.[2];
211
+ return value ? value : undefined;
212
+ }
213
+ function isRecord(value) {
214
+ return !!value && typeof value === "object" && !Array.isArray(value);
215
+ }
@@ -7,16 +7,41 @@ export interface McpToolRecord {
7
7
  readonly agentId?: string;
8
8
  readonly capabilityId: string;
9
9
  readonly serverName: string;
10
+ /** The tool's name on its server, which the call sends. */
10
11
  readonly serverToolName: string;
12
+ /** The name the model knows it by (`normalizeToolName`). */
11
13
  readonly name: string;
12
14
  readonly description?: string;
13
15
  readonly inputSchema: JsonObject;
14
16
  readonly outputSchema?: JsonObject;
17
+ /**
18
+ * The server's hints that calling the tool again is safe (R2b C7, Q15): a call whose answer
19
+ * was lost after it was sent is then `mcp.lost` to the model, not `uncertain`.
20
+ */
21
+ readonly annotations?: {
22
+ readonly readOnlyHint?: boolean;
23
+ readonly idempotentHint?: boolean;
24
+ };
25
+ /**
26
+ * Out of the model's tool list for the session's life (R2b C10): core decides it once, when it
27
+ * records the snapshot (`pinDeferral`), so every model call of the session lists the same tools.
28
+ */
29
+ readonly deferred?: true;
30
+ }
31
+ /** A server's own instructions (MCP `initialize`), for the note on its deferred tools (R2b C10). */
32
+ export interface McpServerNote {
33
+ readonly agentId?: string;
34
+ readonly capabilityId: string;
35
+ readonly serverName: string;
36
+ /** Cut to `SERVER_INSTRUCTIONS_MAX_CHARS`. */
37
+ readonly instructions: string;
15
38
  }
16
39
  export interface McpSnapshot {
17
40
  readonly snapshotSchemaVersion: 1;
18
41
  readonly manifestHash: string;
19
42
  readonly mcpTools: readonly McpToolRecord[];
43
+ /** The servers that gave instructions; absent when none did. */
44
+ readonly servers?: readonly McpServerNote[];
20
45
  }
21
46
  export interface McpDiagnostic {
22
47
  /** The agent used as a tool that declares the server; absent for the session's root agent. */
@@ -26,17 +51,66 @@ export interface McpDiagnostic {
26
51
  readonly outcome: "connected" | "refused" | "failed";
27
52
  readonly message: string;
28
53
  readonly credentialIds?: readonly string[];
54
+ /** Tools the model knows by another name than `server__tool` (R2b C6). */
55
+ readonly renamed?: readonly McpRenamedTool[];
56
+ /** How many listed tools the server's `tools` settings disable (R2b C9). */
57
+ readonly disabled?: number;
58
+ /** Keys of the server's `tools` settings that name no tool it listed (R2b C9). */
59
+ readonly unknownTools?: readonly string[];
60
+ }
61
+ /** A tool whose model-facing name is not `server__tool` (`normalizeToolName`). */
62
+ export interface McpRenamedTool {
63
+ readonly serverToolName: string;
64
+ readonly name: string;
29
65
  }
30
66
  /** The `mcp.discovered` payload: each server's outcome, with the tools it added to the snapshot. */
31
67
  export declare function mcpDiscovered(snapshot: McpSnapshot, diagnostics: readonly McpDiagnostic[]): EventPayload<"mcp.discovered">;
32
- export declare function modelToolName(serverName: string, serverToolName: string): string;
68
+ /** The longest tool name every model provider accepts (R2b C6, Q14). */
69
+ export declare const MODEL_TOOL_NAME_MAX = 64;
70
+ /**
71
+ * The name the model knows an MCP tool by (R2b C6, Q13): `server__tool`, with each character
72
+ * outside `[A-Za-z0-9_-]` replaced by `_`, since MCP allows `.` and 128 characters and OpenAI,
73
+ * Bedrock and Gemini on Vertex do not. A name over 64 characters, or one that collides
74
+ * (`suffixed`), keeps 55 and adds `_` and 8 hex characters of the SHA-256 of the raw
75
+ * `server/tool`.
76
+ */
77
+ export declare function normalizeToolName(serverName: string, serverToolName: string, options?: {
78
+ readonly suffixed?: boolean;
79
+ }): string;
33
80
  export declare function declaredToolNames(manifest: AgentManifest): Set<string>;
34
81
  /**
35
- * The tools of a snapshot, as the engine advertises them. A tool of a server whose `approval` is
36
- * `always` in the session's `manifest` waits for approval on each call.
82
+ * The tools of a snapshot, as the engine gets them for a turn whose manifest is `manifest` (the
83
+ * session's pin, or a turn variant that tightened its MCP servers' tools):
84
+ *
85
+ * - a tool its settings disable is left out (R2b C9); one whose approval resolves to `always`
86
+ * waits for approval on each call;
87
+ * - a deferred tool (R2b C10) is marked so, out of the model's list, and its agent gets
88
+ * `tool_search`, with the note on the servers it holds deferred tools of, and `tool_call` in
89
+ * its `nylorun.tools` capability. An agent whose turn has no such capability keeps its tools in
90
+ * its list.
91
+ *
92
+ * Nothing here changes between the steps of a turn, so neither does the model's tool list.
37
93
  */
38
94
  export declare function sessionToolsOf(snapshot: McpSnapshot | undefined, manifest: AgentManifest): readonly DurableSessionTool[] | undefined;
39
- /** The snapshot's record of an MCP tool call, or undefined when the call is not an MCP tool. */
40
- export declare function mcpToolOf(snapshot: McpSnapshot | undefined, request: Pick<HostEffect, "agent" | "capabilityId" | "toolName">): McpToolRecord | undefined;
95
+ /** The share of the model's context window past which an agent's MCP tools are deferred (Q21). */
96
+ export declare const DEFER_CONTEXT_SHARE = 0.1;
97
+ /** Characters per token, to estimate what tool definitions take of a context window (Q21). */
98
+ export declare const CHARS_PER_TOKEN = 4;
99
+ /**
100
+ * The snapshot with its deferred tools marked (R2b C10, Q21), decided once for the session when
101
+ * core records it. A tool's own setting, then `"*"`'s, then its server's, decides; a tool none
102
+ * sets is deferred when the JSON of its agent's MCP tools that would be listed passes a tenth of
103
+ * the model's context window (`contextWindow` tokens, at 4 characters a token). An agent without
104
+ * the `nylorun.tools` capability (its definition names a tool `tool_search` or `tool_call`, or the
105
+ * session was opened before it existed) defers nothing.
106
+ */
107
+ export declare function pinDeferral(snapshot: McpSnapshot, manifest: AgentManifest, contextWindow: number): McpSnapshot;
108
+ /**
109
+ * The snapshot's record of an MCP tool call, or undefined when the call is not an MCP tool. With
110
+ * the turn's `manifest`, a tool its settings disable is not one either (R2b C9).
111
+ */
112
+ export declare function mcpToolOf(snapshot: McpSnapshot | undefined, request: Pick<HostEffect, "agent" | "capabilityId" | "toolName">, manifest?: AgentManifest): McpToolRecord | undefined;
113
+ /** True when the tool's settings in `manifest` leave it enabled (R2b C9). */
114
+ export declare function isEnabledIn(manifest: AgentManifest, tool: McpToolRecord): boolean;
41
115
  /** The manifest of the agent an effect belongs to: the root, or an agent it uses as a tool. */
42
116
  export declare function manifestFor(manifest: AgentManifest, agent: HostEffect["agent"]): AgentManifest | undefined;