@nylorun/runtime 0.20.0-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 +64 -0
- package/README.md +15 -10
- package/dist/api/components.d.ts +155 -36
- package/dist/api/components.js +3 -3
- package/dist/api/http/app.js +2 -0
- 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 +6 -3
- package/dist/artifacts/media-types.d.ts +2 -0
- package/dist/artifacts/media-types.js +5 -0
- 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/config-for.d.ts +0 -2
- package/dist/host/config-for.js +0 -1
- package/dist/host/gates.js +0 -1
- package/dist/host/main.js +13 -3
- package/dist/host/stack-config.d.ts +5 -8
- package/dist/host/stack-config.js +8 -19
- package/dist/index.d.ts +0 -1
- 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 +315 -345
- 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 +362 -123
- 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/drizzle/0016_mcp_oauth_removed.sql +5 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +2 -261
- package/dist/store/postgres/schema.js +2 -36
- package/dist/store/postgres/store.js +1 -18
- package/dist/store/postgres/tenant.d.ts +14 -0
- package/dist/store/postgres/tenant.js +22 -2
- package/dist/store/types.d.ts +2 -8
- package/dist/tenant/artifact-tool.d.ts +9 -1
- package/dist/tenant/artifact-tool.js +113 -9
- package/dist/tenant/context.d.ts +5 -6
- 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 +0 -1
- package/dist/tenant/outbound.d.ts +14 -5
- package/dist/tenant/outbound.js +21 -9
- package/dist/tenant/runtime.js +9 -14
- package/dist/tenant/sessions.js +20 -3
- package/dist/tenant/store-pg.js +9 -0
- 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/vault/oauth.d.ts +0 -55
- package/dist/vault/oauth.js +0 -186
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema,
|
|
3
|
-
import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest,
|
|
4
|
-
import { HttpError } from "../../../tenant/http.js";
|
|
5
|
-
import { VaultError } from "../../../vault/error.js";
|
|
2
|
+
import { CreateCredentialRequestSchema, CreateVaultRequestSchema, RotateCredentialRequestSchema, } from "@nylorun/core/contracts";
|
|
3
|
+
import { CreateCredentialRequest, CreateVaultRequest, CredentialInfo, DeletedResponse, ListCredentialsResponse, ListVaultsResponse, RotateCredentialRequest, VaultInfo, } from "../../components.js";
|
|
6
4
|
import { readJson } from "../body.js";
|
|
7
5
|
import { tenantRoute } from "../define.js";
|
|
8
6
|
import { jsonResponse } from "../respond.js";
|
|
9
7
|
/** The Management API's vault routes (`/v1/tenant/vaults`): a management key. */
|
|
10
8
|
const MANAGEMENT = { credentials: ["management"], scopes: "never" };
|
|
11
|
-
/**
|
|
12
|
-
* The OAuth callback: a browser sent back by the authorization server, with no credential and no
|
|
13
|
-
* protocol header. The `state` it carries is the grant.
|
|
14
|
-
*/
|
|
15
|
-
const CALLBACK = {
|
|
16
|
-
credentials: ["application", "subject", "token"],
|
|
17
|
-
scopes: "any",
|
|
18
|
-
anonymous: true,
|
|
19
|
-
unversioned: true,
|
|
20
|
-
};
|
|
21
|
-
/** Where the authorization server sends the browser back. */
|
|
22
|
-
export const OAUTH_CALLBACK_PATH = "/v1/oauth/callback";
|
|
23
9
|
const json = (schema, description) => ({
|
|
24
10
|
description,
|
|
25
11
|
content: { "application/json": { schema } },
|
|
@@ -94,7 +80,7 @@ function vaultRoutesAt(api, base, access) {
|
|
|
94
80
|
path: `${base}/{vaultId}/credentials`,
|
|
95
81
|
tags: ["Vaults"],
|
|
96
82
|
summary: "Add a credential",
|
|
97
|
-
description: "A bearer token
|
|
83
|
+
description: "A bearer token, bound to the URL a tool may send it to.",
|
|
98
84
|
request: { params: vaultId, body: body(CreateCredentialRequest) },
|
|
99
85
|
responses: { 200: json(CredentialInfo, "The credential, without its secret") },
|
|
100
86
|
}, async (c) => {
|
|
@@ -147,93 +133,7 @@ function vaultRoutesAt(api, base, access) {
|
|
|
147
133
|
}, async (c) => {
|
|
148
134
|
return jsonResponse(200, await c.env.tenant.vault.deleteCredential(c.req.param("vaultId"), c.req.param("credentialId")));
|
|
149
135
|
});
|
|
150
|
-
tenantRoute(api, access, {
|
|
151
|
-
method: "post",
|
|
152
|
-
path: `${base}/{vaultId}/oauth/start`,
|
|
153
|
-
tags: ["Vaults"],
|
|
154
|
-
summary: "Start an MCP OAuth connect",
|
|
155
|
-
description: "Signs the installation in to the remote MCP server at `url` (declared as `server`) and stores its OAuth credential, bound to `url`, in this installation vault. The Runtime discovers the server's authorization server (RFC 9728, RFC 8414), registers itself (RFC 7591) unless `clientId` names a registered client, and answers the URL to open in a browser; the sign-in returns to `GET /v1/oauth/callback` within `expiresAt`. The callback URL is `NYLORUN_PUBLIC_URL` + `/v1/oauth/callback`, or this request's own origin when the Host has no public URL. Application keys acting for no one only. `oauth_client_required` when the server offers no registration and no `clientId` was given.",
|
|
156
|
-
request: { params: vaultId, body: body(StartOAuthRequest) },
|
|
157
|
-
responses: {
|
|
158
|
-
200: json(StartOAuthResponse, "Where to send the browser"),
|
|
159
|
-
502: {
|
|
160
|
-
description: "The authorization server, or its discovery, failed or refused (`oauth_failed`)",
|
|
161
|
-
},
|
|
162
|
-
},
|
|
163
|
-
}, async (c) => {
|
|
164
|
-
const request = StartOAuthRequestSchema.parse(await readJson(c.req.raw));
|
|
165
|
-
const tenant = c.env.tenant;
|
|
166
|
-
const base = tenant.config.publicUrl ?? new URL(c.req.url).origin;
|
|
167
|
-
return jsonResponse(200, await tenant.keys.startOAuth({
|
|
168
|
-
vaultId: c.req.param("vaultId"),
|
|
169
|
-
server: request.server,
|
|
170
|
-
url: request.url,
|
|
171
|
-
...(request.clientId === undefined ? {} : { clientId: request.clientId }),
|
|
172
|
-
redirectUri: `${base}${OAUTH_CALLBACK_PATH}`,
|
|
173
|
-
}));
|
|
174
|
-
});
|
|
175
136
|
}
|
|
176
137
|
export function vaultRoutes(api) {
|
|
177
138
|
vaultRoutesAt(api, "/v1/tenant/vaults", MANAGEMENT);
|
|
178
|
-
tenantRoute(api, CALLBACK, {
|
|
179
|
-
method: "get",
|
|
180
|
-
path: OAUTH_CALLBACK_PATH,
|
|
181
|
-
tags: ["Vaults"],
|
|
182
|
-
summary: "Finish an MCP OAuth connect",
|
|
183
|
-
description: "Where the authorization server sends the browser back with `code` and `state` (or `error`). Exchanges the code once and stores the credential; answers a small HTML page. Needs no credential and no `Nylorun-Protocol`. A `state` is used once, for ten minutes (`oauth_state_invalid`).",
|
|
184
|
-
request: {
|
|
185
|
-
query: z.object({
|
|
186
|
-
code: z.string().optional(),
|
|
187
|
-
state: z.string().optional(),
|
|
188
|
-
error: z.string().optional(),
|
|
189
|
-
}),
|
|
190
|
-
},
|
|
191
|
-
responses: {
|
|
192
|
-
200: { description: "Connected", content: { "text/html": { schema: z.string() } } },
|
|
193
|
-
502: { description: "The authorization server refused the code (`oauth_failed`)" },
|
|
194
|
-
},
|
|
195
|
-
}, async (c) => {
|
|
196
|
-
const state = c.req.query("state");
|
|
197
|
-
const code = c.req.query("code");
|
|
198
|
-
const error = c.req.query("error");
|
|
199
|
-
if (!state)
|
|
200
|
-
return page(400, "Sign-in failed", "The authorization server sent no state. Start the connect again.");
|
|
201
|
-
try {
|
|
202
|
-
await c.env.tenant.keys.finishOAuth({
|
|
203
|
-
state,
|
|
204
|
-
...(code === undefined ? {} : { code }),
|
|
205
|
-
...(error === undefined ? {} : { error }),
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
catch (failure) {
|
|
209
|
-
if (failure instanceof HttpError || failure instanceof VaultError)
|
|
210
|
-
return page(failure.status, "Sign-in failed", failure.message);
|
|
211
|
-
c.env.tenant.config.logger.error("oauth_callback_failed", {
|
|
212
|
-
error: failure instanceof Error ? failure.name : "unknown",
|
|
213
|
-
});
|
|
214
|
-
return page(500, "Sign-in failed", "The connect failed. Start it again.");
|
|
215
|
-
}
|
|
216
|
-
return page(200, "Connected", "Connected. You can close this tab.");
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
const ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
220
|
-
const escape = (text) => text.replace(/[&<>"']/g, (char) => ESCAPES[char]);
|
|
221
|
-
/** The callback's answer: a small page that loads nothing, is never cached and leaks no referrer. */
|
|
222
|
-
function page(status, title, message) {
|
|
223
|
-
const html = `<!doctype html>
|
|
224
|
-
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
225
|
-
<title>${escape(title)} · Nylorun</title>
|
|
226
|
-
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;color:#1a1a1a;background:#fff}@media (prefers-color-scheme:dark){body{color:#eee;background:#111}}h1{font-size:1.25rem}</style>
|
|
227
|
-
</head><body><h1>${escape(title)}</h1><p>${escape(message)}</p></body></html>
|
|
228
|
-
`;
|
|
229
|
-
return new Response(html, {
|
|
230
|
-
status,
|
|
231
|
-
headers: {
|
|
232
|
-
"content-type": "text/html; charset=utf-8",
|
|
233
|
-
"cache-control": "no-store",
|
|
234
|
-
"referrer-policy": "no-referrer",
|
|
235
|
-
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
236
|
-
"x-content-type-options": "nosniff",
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
139
|
}
|
package/dist/api/openapi.js
CHANGED
|
@@ -236,7 +236,7 @@ const MANAGEMENT_TAGS = [
|
|
|
236
236
|
},
|
|
237
237
|
{
|
|
238
238
|
name: "Vaults",
|
|
239
|
-
description: "The installation's credentials for agents' tools: API keys
|
|
239
|
+
description: "The installation's and each person's credentials for agents' tools: API keys, bound to the URL they are sent to. Sessions attach a vault by id. `admin.vaults`.",
|
|
240
240
|
operations: [
|
|
241
241
|
"POST /v1/tenant/vaults",
|
|
242
242
|
"GET /v1/tenant/vaults",
|
|
@@ -247,10 +247,13 @@ const MANAGEMENT_TAGS = [
|
|
|
247
247
|
"GET /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
|
|
248
248
|
"POST /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
|
|
249
249
|
"DELETE /v1/tenant/vaults/{vaultId}/credentials/{credentialId}",
|
|
250
|
-
"POST /v1/tenant/vaults/{vaultId}/oauth/start",
|
|
251
|
-
"GET /v1/oauth/callback",
|
|
252
250
|
],
|
|
253
251
|
},
|
|
252
|
+
{
|
|
253
|
+
name: "MCP servers",
|
|
254
|
+
description: "See a remote MCP server's tools before an agent names it, with the installation vault's credential for its URL: their model names, sizes and annotations, or that it needs a person's sign-in. `admin.mcp`.",
|
|
255
|
+
operations: ["POST /v1/tenant/mcp/preview"],
|
|
256
|
+
},
|
|
254
257
|
{
|
|
255
258
|
name: "Signing keys",
|
|
256
259
|
description: "The keys the Runtime signs capability links and run tokens with; their public halves are the Runtime API's JWKS. `admin.signingKeys`.",
|
|
@@ -11,3 +11,5 @@ export declare function contentTypeFor(name: string): string;
|
|
|
11
11
|
export declare function isModelImage(contentType: string): boolean;
|
|
12
12
|
/** Files the model reads as text: `text/*`, JSON, XML, YAML and their `+json`/`+xml` kin. */
|
|
13
13
|
export declare function isText(contentType: string): boolean;
|
|
14
|
+
/** The usual extension of a media type, for a name the Runtime gives a file; undefined if none. */
|
|
15
|
+
export declare function extensionFor(contentType: string): string | undefined;
|
|
@@ -78,3 +78,8 @@ export function isText(contentType) {
|
|
|
78
78
|
type.endsWith("+json") ||
|
|
79
79
|
type.endsWith("+xml"));
|
|
80
80
|
}
|
|
81
|
+
/** The usual extension of a media type, for a name the Runtime gives a file; undefined if none. */
|
|
82
|
+
export function extensionFor(contentType) {
|
|
83
|
+
const type = essence(contentType);
|
|
84
|
+
return Object.keys(BY_EXTENSION).find((extension) => BY_EXTENSION[extension] === type);
|
|
85
|
+
}
|
|
@@ -11,10 +11,17 @@ export declare const HTTP_ERROR_BODY_CHARS = 2000;
|
|
|
11
11
|
export type HttpOutcome = {
|
|
12
12
|
readonly kind: "completed";
|
|
13
13
|
readonly output: unknown;
|
|
14
|
+
/** How many credential values the answer echoed, replaced with `[redacted]` (R2b C8). */
|
|
15
|
+
readonly redacted?: number;
|
|
14
16
|
} | {
|
|
15
17
|
readonly kind: "failed";
|
|
16
18
|
readonly code: string;
|
|
17
19
|
readonly message: string;
|
|
20
|
+
/** With `credential_rejected`: the tool's `credential`, and the scope of its vault. */
|
|
21
|
+
readonly server?: string;
|
|
22
|
+
readonly vault?: "installation" | "user";
|
|
23
|
+
/** How many credential values the message had in it, replaced (R2b C8). */
|
|
24
|
+
readonly redacted?: number;
|
|
18
25
|
};
|
|
19
26
|
/**
|
|
20
27
|
* One declared HTTP tool of a session: what the gate finds it by in the pinned manifest. An
|
package/dist/gates/http-tool.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { AGENT_ID_HEADER, SESSION_ID_HEADER, TURN_ID_HEADER, } from "@nylorun/core/compatibility";
|
|
17
17
|
import { HTTP_TOOL_DEFAULT_TIMEOUT_MS, delegateManifest, flowHttpTarget, isWorkflowManifest, schemaFromJSON, } from "@nylorun/core/define";
|
|
18
|
+
import { scrubValues } from "../redact.js";
|
|
18
19
|
import { MAX_RESPONSE_BYTES, post } from "../tenant/outbound.js";
|
|
20
|
+
import { credentialSecrets } from "../vault/headers.js";
|
|
19
21
|
/** How much of a failed answer's body the model sees. */
|
|
20
22
|
export const HTTP_ERROR_BODY_CHARS = 2_000;
|
|
21
23
|
/** The HTTP tool `capabilityId`/`toolName` of the agent `agentId` (the root when absent). */
|
|
@@ -83,6 +85,9 @@ export async function callHttpTarget(target, input, options) {
|
|
|
83
85
|
case "too_large":
|
|
84
86
|
return failed("http.too-large", `The answer (HTTP ${result.status}) is larger than ${MAX_RESPONSE_BYTES} bytes`);
|
|
85
87
|
}
|
|
88
|
+
// The service answered, so the outcome is known; never retried, and its body is not shown (R2b C1).
|
|
89
|
+
if (result.status === 401)
|
|
90
|
+
return failed("credential_rejected", "The service answered HTTP 401: it rejected the credential, or needs one");
|
|
86
91
|
const text = result.body.toString("utf8");
|
|
87
92
|
if (result.status < 200 || result.status > 299) {
|
|
88
93
|
const body = text.length > HTTP_ERROR_BODY_CHARS ? `${text.slice(0, HTTP_ERROR_BODY_CHARS)}…` : text;
|
|
@@ -156,20 +161,26 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
156
161
|
: `'${tool.toolName}' is not an HTTP tool of the session`);
|
|
157
162
|
const { http } = declared;
|
|
158
163
|
let credential = {};
|
|
164
|
+
let identity;
|
|
165
|
+
let target = http;
|
|
166
|
+
let vault;
|
|
159
167
|
if (http.credential !== undefined) {
|
|
160
168
|
const authorized = await tenant.authorize(tool.sessionId, {
|
|
161
|
-
kind: "http",
|
|
162
169
|
url: http.url,
|
|
163
170
|
serverName: http.credential,
|
|
164
|
-
...(agentId === undefined ? {} : { agentId }),
|
|
165
171
|
});
|
|
166
172
|
if (authorized.status === "refused")
|
|
167
173
|
return failed("http.credential", `The credential '${http.credential}' was refused: ${authorized.reason}`);
|
|
168
174
|
if (authorized.status === "unauthenticated")
|
|
169
175
|
return failed("http.credential", `The session's vaults hold no credential '${http.credential}' for ${authorized.url}`);
|
|
170
176
|
credential = authorized.headers;
|
|
177
|
+
identity = authorized.identity;
|
|
178
|
+
vault = authorized.vault;
|
|
179
|
+
// A credential with `via` sends the call there, a gateway (R2b C2, Q9).
|
|
180
|
+
if (authorized.via !== undefined)
|
|
181
|
+
target = { ...http, url: authorized.via };
|
|
171
182
|
}
|
|
172
|
-
|
|
183
|
+
const answered = await callHttpTarget(target, call.input, {
|
|
173
184
|
headers: {
|
|
174
185
|
...credential,
|
|
175
186
|
[SESSION_ID_HEADER]: tool.sessionId,
|
|
@@ -181,6 +192,17 @@ export async function runHttpTool(tenant, call, options) {
|
|
|
181
192
|
signal: options.signal,
|
|
182
193
|
...(declared.outputSchema === undefined ? {} : { outputSchema: declared.outputSchema }),
|
|
183
194
|
});
|
|
195
|
+
// Nothing the service echoes of the credential reaches the model, an event or the gate's
|
|
196
|
+
// record of the call: its answer and the start of an error body alike (R2b C8).
|
|
197
|
+
const outcome = scrubbedOutcome(answered, credentialSecrets(credential, identity));
|
|
198
|
+
if (outcome.kind !== "failed" || outcome.code !== "credential_rejected" || http.credential === undefined)
|
|
199
|
+
return outcome;
|
|
200
|
+
return {
|
|
201
|
+
...outcome,
|
|
202
|
+
message: `The service answered HTTP 401: it rejected the credential '${http.credential}' from the ${vault} vault`,
|
|
203
|
+
server: http.credential,
|
|
204
|
+
...(vault === undefined ? {} : { vault }),
|
|
205
|
+
};
|
|
184
206
|
}
|
|
185
207
|
}
|
|
186
208
|
/** The gates service's HTTP tool calls, with the Tenant's vault (`host/gates.ts`). */
|
|
@@ -195,3 +217,12 @@ export function gateHttpTools(vaults, policy, logger) {
|
|
|
195
217
|
function failed(code, message) {
|
|
196
218
|
return { kind: "failed", code, message };
|
|
197
219
|
}
|
|
220
|
+
/** `outcome` with each of `secrets` replaced by `[redacted]`, and how many were. */
|
|
221
|
+
function scrubbedOutcome(outcome, secrets) {
|
|
222
|
+
if (outcome.kind === "completed") {
|
|
223
|
+
const output = scrubValues(outcome.output, secrets);
|
|
224
|
+
return output.redacted === 0 ? outcome : { ...outcome, output: output.value, redacted: output.redacted };
|
|
225
|
+
}
|
|
226
|
+
const message = scrubValues(outcome.message, secrets);
|
|
227
|
+
return message.redacted === 0 ? outcome : { ...outcome, message: message.value, redacted: message.redacted };
|
|
228
|
+
}
|
|
@@ -18,7 +18,10 @@ export interface McpHandlerOptions {
|
|
|
18
18
|
readonly delivery?: OutboundPolicy;
|
|
19
19
|
}
|
|
20
20
|
export interface McpHandler {
|
|
21
|
-
|
|
21
|
+
/** Opens the connection; answers the server's instructions when it gave any (R2b C10). */
|
|
22
|
+
connect(tenantId: string | undefined, server: McpServerRef): Promise<McpAnswer<{
|
|
23
|
+
instructions: string;
|
|
24
|
+
} | null>>;
|
|
22
25
|
list(tenantId: string | undefined, server: McpServerRef, cursor: string | undefined, signal: AbortSignal): Promise<McpAnswer<McpToolPage>>;
|
|
23
26
|
/** One `tools/call`; the raw `CallToolResult` on success. */
|
|
24
27
|
call(tenantId: string | undefined, request: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openMcpServer, } from "../mcp/connect.js";
|
|
1
|
+
import { McpCallFailed, McpConnectionStale, openMcpServer, unreachable, } from "../mcp/connect.js";
|
|
2
2
|
import { findServer } from "../mcp/pool.js";
|
|
3
3
|
import { GateRefusal } from "./tenant-vaults.js";
|
|
4
4
|
/** A connection unused this long is closed; the next request opens it again. */
|
|
@@ -65,29 +65,79 @@ export function createMcpHandler(options) {
|
|
|
65
65
|
...fields(server),
|
|
66
66
|
...extra,
|
|
67
67
|
ms: now() - started,
|
|
68
|
-
outcome: failed.code === undefined ? "failed" : String(failed.code),
|
|
68
|
+
outcome: failed.failure?.code ?? (failed.code === undefined ? "failed" : String(failed.code)),
|
|
69
69
|
});
|
|
70
70
|
return { ok: false, error: failed };
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
/** The server's connection; for a tool call, one that cannot be opened is `mcp.unreachable`. */
|
|
74
|
+
async function opened(tenantId, server, call) {
|
|
75
|
+
try {
|
|
76
|
+
return await connection(tenantId, server);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// The call was never sent, so the model may try again (R2b C7). A gate that cannot serve
|
|
80
|
+
// the session is not the server's failure, and stays as it was.
|
|
81
|
+
if (!call || error instanceof GateRefusal || error instanceof NotDeclared)
|
|
82
|
+
throw error;
|
|
83
|
+
throw unreachable(server.serverName, error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function use(tenantId, server, run, call = false) {
|
|
87
|
+
const entry = await opened(tenantId, server, call);
|
|
75
88
|
entry.active += 1;
|
|
76
89
|
try {
|
|
77
90
|
return await run(entry);
|
|
78
91
|
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
// The connection can no longer make requests: the next one opens a new connection, and
|
|
94
|
+
// the calls still running on this one finish first (the last closes it).
|
|
95
|
+
if (error instanceof McpConnectionStale) {
|
|
96
|
+
const key = keyOf(server);
|
|
97
|
+
if (live.get(key) === entry)
|
|
98
|
+
live.delete(key);
|
|
99
|
+
entry.evicted = true;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
79
103
|
finally {
|
|
80
104
|
entry.active -= 1;
|
|
81
105
|
entry.lastUsedAt = now();
|
|
106
|
+
if (entry.evicted && entry.active === 0)
|
|
107
|
+
void entry.connection.close().catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* One `tools/call`. A connection found stale before the tool saw the call is opened again, and
|
|
112
|
+
* the call sent once more (R2b C7).
|
|
113
|
+
*/
|
|
114
|
+
async function callOnce(tenantId, request, signal) {
|
|
115
|
+
for (let attempt = 0;; attempt += 1) {
|
|
116
|
+
try {
|
|
117
|
+
return await use(tenantId, request.server, (entry) => entry.connection.client.callTool({ name: request.name, arguments: request.arguments }, { signal }), true);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (!(error instanceof McpConnectionStale) || attempt > 0)
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
82
123
|
}
|
|
83
124
|
}
|
|
84
125
|
return {
|
|
85
126
|
connect: (tenantId, server) => answer("connect", server, async () => {
|
|
86
|
-
await connection(tenantId, server);
|
|
87
|
-
|
|
127
|
+
const { instructions } = (await connection(tenantId, server)).connection;
|
|
128
|
+
// The server's instructions, for the note on its deferred tools (R2b C10).
|
|
129
|
+
return instructions === undefined ? null : { instructions };
|
|
88
130
|
}),
|
|
89
131
|
list: (tenantId, server, cursor, signal) => answer("list", server, () => use(tenantId, server, (entry) => entry.connection.client.listTools(cursor ? { cursor } : undefined, { signal }))),
|
|
90
|
-
|
|
132
|
+
async call(tenantId, request, signal) {
|
|
133
|
+
const answered = await answer("call", request.server, () => callOnce(tenantId, request, signal), {
|
|
134
|
+
effect: request.effectId,
|
|
135
|
+
});
|
|
136
|
+
if (!answered.ok)
|
|
137
|
+
return answered;
|
|
138
|
+
const { result, redacted } = answered.result;
|
|
139
|
+
return { ok: true, result, ...(redacted ? { redacted } : {}) };
|
|
140
|
+
},
|
|
91
141
|
async close(server) {
|
|
92
142
|
const key = keyOf(server);
|
|
93
143
|
const entry = live.get(key);
|
|
@@ -125,10 +175,16 @@ function fields(server) {
|
|
|
125
175
|
...(server.agentId === undefined ? {} : { agent: server.agentId }),
|
|
126
176
|
};
|
|
127
177
|
}
|
|
128
|
-
/** An error as the loop's `diagnosticFromError` reads it. */
|
|
178
|
+
/** An error as the loop's `diagnosticFromError` reads it, with a coded failure's code (R2b C7). */
|
|
129
179
|
function errorOf(error) {
|
|
130
180
|
if (error instanceof GateRefusal)
|
|
131
181
|
return { message: error.outcome.message };
|
|
182
|
+
if (error instanceof McpCallFailed)
|
|
183
|
+
return {
|
|
184
|
+
message: error.message,
|
|
185
|
+
...(error.code === "credential_rejected" ? { code: 401 } : {}),
|
|
186
|
+
failure: error.failure,
|
|
187
|
+
};
|
|
132
188
|
const message = error instanceof Error ? error.message.slice(0, 2000) : String(error);
|
|
133
189
|
const code = error && typeof error === "object" && "code" in error && typeof error.code === "number"
|
|
134
190
|
? error.code
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
import type { Credential } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ModelFailureOutcome } from "@nylorun/core/define";
|
|
11
11
|
import type { PostgresClient } from "../store/postgres/connect.js";
|
|
12
|
-
import { type AuthorizeResult, type HostModelSecret } from "../vault/service.js";
|
|
13
|
-
import {
|
|
12
|
+
import { VaultService, type AuthorizeResult, type HostModelSecret } from "../vault/service.js";
|
|
13
|
+
import { type McpCredentialRequest } from "../vault/sources.js";
|
|
14
14
|
import type { SessionStore } from "../store/types.js";
|
|
15
|
+
import type { OutboundPolicy } from "../tenant/outbound.js";
|
|
15
16
|
import type { Session } from "../tenant/context.js";
|
|
16
17
|
import { type Keys } from "../keys/keys.js";
|
|
17
|
-
import { type OutboundPolicy } from "../tenant/outbound.js";
|
|
18
18
|
/** The Tenant, as a model call needs it. */
|
|
19
19
|
export interface TenantVault {
|
|
20
20
|
readonly tenantId: string;
|
|
@@ -29,8 +29,7 @@ export interface TenantVault {
|
|
|
29
29
|
session(sessionId: string): Promise<Session | undefined>;
|
|
30
30
|
/**
|
|
31
31
|
* The authorization of one request to a session's remote MCP server or HTTP tool: its
|
|
32
|
-
* credential from the session's attached vaults
|
|
33
|
-
* operator's credential resolver (F9 C1).
|
|
32
|
+
* credential from the session's attached vaults.
|
|
34
33
|
*/
|
|
35
34
|
authorizeMcp(sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
|
|
36
35
|
/** Vault writes and token signing with the Tenant's vault key (the keys service, F4.2). */
|
|
@@ -52,18 +51,13 @@ export interface TenantVaultsOptions {
|
|
|
52
51
|
readonly sql: PostgresClient;
|
|
53
52
|
/** The Host root; the gate reads `keys/vault-kek` and `tenant/home` under it. */
|
|
54
53
|
readonly hostRoot: string;
|
|
55
|
-
/**
|
|
56
|
-
readonly resolver?: ResolverConfig;
|
|
57
|
-
/**
|
|
58
|
-
* The Host's address policy (`NYLORUN_ENDPOINT_*`): OAuth refresh and MCP OAuth connect call
|
|
59
|
-
* authorization servers through `guardedFetch` under it (F9 C2).
|
|
60
|
-
*/
|
|
54
|
+
/** How the keys service's tool preview reaches a server (`NYLORUN_ENDPOINT_*`, R2b C12). */
|
|
61
55
|
readonly delivery?: OutboundPolicy;
|
|
62
56
|
}
|
|
63
57
|
export declare function createTenantVaults(options: TenantVaultsOptions): TenantVaults;
|
|
64
58
|
/**
|
|
65
|
-
* A request to a session's remote MCP server, authorized from the session's attached vaults
|
|
66
|
-
*
|
|
67
|
-
*
|
|
59
|
+
* A request to a session's remote MCP server, authorized from the session's attached vaults:
|
|
60
|
+
* what the loop does in its own process (`tenant/effects.ts` `authorize`). The vaults come from
|
|
61
|
+
* the session row.
|
|
68
62
|
*/
|
|
69
|
-
export declare function authorizeSessionMcp(
|
|
63
|
+
export declare function authorizeSessionMcp(vault: Pick<VaultService, "authorize">, session: (sessionId: string) => Promise<Session | undefined>, sessionId: string, request: McpCredentialRequest): Promise<AuthorizeResult>;
|
|
@@ -8,10 +8,9 @@ import { tenantPaths } from "../tenant/paths.js";
|
|
|
8
8
|
import { readVaultKek } from "../vault/kek.js";
|
|
9
9
|
import { HostModelVault } from "../vault/host-model.js";
|
|
10
10
|
import { VaultService } from "../vault/service.js";
|
|
11
|
-
import {
|
|
11
|
+
import { sessionCredentials } from "../vault/sources.js";
|
|
12
12
|
import { inProcessKeys } from "../keys/keys.js";
|
|
13
13
|
import { SigningKeys } from "../tenant/signing-keys.js";
|
|
14
|
-
import { guardedFetch } from "../tenant/outbound.js";
|
|
15
14
|
/** A call the gate refuses before the provider is called; it answers with `outcome`. */
|
|
16
15
|
export class GateRefusal extends Error {
|
|
17
16
|
outcome;
|
|
@@ -35,20 +34,13 @@ export function createTenantVaults(options) {
|
|
|
35
34
|
return kek;
|
|
36
35
|
};
|
|
37
36
|
const vault = new HostModelVault({ store, kek: readKek });
|
|
38
|
-
const credentials = new VaultService({
|
|
39
|
-
store,
|
|
40
|
-
kek: readKek,
|
|
41
|
-
fetch: guardedFetch(options.delivery ?? {}),
|
|
42
|
-
});
|
|
43
|
-
const sources = new CredentialSources({
|
|
44
|
-
vault: credentials,
|
|
45
|
-
...(options.resolver ? { resolver: options.resolver } : {}),
|
|
46
|
-
});
|
|
37
|
+
const credentials = new VaultService({ store, kek: readKek });
|
|
47
38
|
const keys = inProcessKeys({
|
|
48
39
|
store,
|
|
49
40
|
vault: credentials,
|
|
50
41
|
signingKeys: new SigningKeys({ tenantId, kek: readKek }),
|
|
51
42
|
kek: readKek,
|
|
43
|
+
...(options.delivery ? { policy: options.delivery } : {}),
|
|
52
44
|
});
|
|
53
45
|
const session = (sessionId) => store.tx((t) => t.get("sessions", sessionId));
|
|
54
46
|
return {
|
|
@@ -58,7 +50,7 @@ export function createTenantVaults(options) {
|
|
|
58
50
|
readHostModel: () => vault.readHostModel(),
|
|
59
51
|
writeHostCredential: (credential) => vault.updateHostCredential(credential),
|
|
60
52
|
session,
|
|
61
|
-
authorizeMcp: (sessionId, request) => authorizeSessionMcp(
|
|
53
|
+
authorizeMcp: (sessionId, request) => authorizeSessionMcp(credentials, session, sessionId, request),
|
|
62
54
|
keys: () => keys,
|
|
63
55
|
};
|
|
64
56
|
}
|
|
@@ -87,13 +79,13 @@ export function createTenantVaults(options) {
|
|
|
87
79
|
};
|
|
88
80
|
}
|
|
89
81
|
/**
|
|
90
|
-
* A request to a session's remote MCP server, authorized from the session's attached vaults
|
|
91
|
-
*
|
|
92
|
-
*
|
|
82
|
+
* A request to a session's remote MCP server, authorized from the session's attached vaults:
|
|
83
|
+
* what the loop does in its own process (`tenant/effects.ts` `authorize`). The vaults come from
|
|
84
|
+
* the session row.
|
|
93
85
|
*/
|
|
94
|
-
export async function authorizeSessionMcp(
|
|
86
|
+
export async function authorizeSessionMcp(vault, session, sessionId, request) {
|
|
95
87
|
const found = await session(sessionId);
|
|
96
88
|
if (!found)
|
|
97
89
|
throw new Error(`Session ${sessionId} not found`);
|
|
98
|
-
return
|
|
90
|
+
return sessionCredentials(vault, { ...found, id: sessionId }, request);
|
|
99
91
|
}
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* has finished.
|
|
7
7
|
*
|
|
8
8
|
* Failures keep the meaning they had in the loop's own process:
|
|
9
|
-
* - An MCP request that fails
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* - An MCP request that fails throws, so the pool reports a diagnostic. A tool call's coded
|
|
10
|
+
* failure (R2b C7) throws the `McpCallFailed` the loop's own call would have, which becomes a
|
|
11
|
+
* failed outcome the model sees. Only the hop's failures and a call lost with the gateway leave
|
|
12
|
+
* it `uncertain`.
|
|
13
|
+
* - An HTTP tool call throws only for the hop, or a call lost with the gateway: its own failures
|
|
14
|
+
* are outcomes.
|
|
12
15
|
*
|
|
13
16
|
* Credentials (F5): a session's MCP requests and keyed cancels carry its run token, read per
|
|
14
17
|
* request from the advance's grant, and leave the session out of the body: the gate takes it
|
|
@@ -19,6 +22,7 @@
|
|
|
19
22
|
import { randomUUID } from "node:crypto";
|
|
20
23
|
import { request as httpRequest } from "node:http";
|
|
21
24
|
import { request as httpsRequest } from "node:https";
|
|
25
|
+
import { McpCallFailed, } from "../mcp/connect.js";
|
|
22
26
|
import { TENANT_HEADER } from "./contract.js";
|
|
23
27
|
import { GATE_CLIENT_TIMEOUT_MS } from "./http-client.js";
|
|
24
28
|
import { HTTP_CALLS_PATH, MCP_CLOSE_PATH, MCP_CONNECT_PATH, MCP_LIST_PATH, TOOL_CALLS_PATH, } from "./tool-contract.js";
|
|
@@ -106,10 +110,11 @@ export function httpToolGate(options) {
|
|
|
106
110
|
return { body: body(unnamed), bearer: token };
|
|
107
111
|
}
|
|
108
112
|
/**
|
|
109
|
-
* The
|
|
110
|
-
* before
|
|
113
|
+
* The answer of an MCP or HTTP tool request, or a throw: a coded MCP failure (`McpCallFailed`),
|
|
114
|
+
* or one the pool and `resolveEffect` read as before, the call's fate unknown and the effect
|
|
115
|
+
* `uncertain`.
|
|
111
116
|
*/
|
|
112
|
-
async function
|
|
117
|
+
async function answer(path, scope, request) {
|
|
113
118
|
const result = await exchange(path, scope.body, {
|
|
114
119
|
...request,
|
|
115
120
|
timeoutMs,
|
|
@@ -123,9 +128,12 @@ export function httpToolGate(options) {
|
|
|
123
128
|
if (result.status !== 200 || !parsed || !("ok" in parsed))
|
|
124
129
|
throw new Error(gateRefusal(result.status, parsed));
|
|
125
130
|
if (parsed.ok)
|
|
126
|
-
return parsed
|
|
131
|
+
return parsed;
|
|
127
132
|
throw mcpError(parsed.error);
|
|
128
133
|
}
|
|
134
|
+
async function answered(path, scope, request) {
|
|
135
|
+
return (await answer(path, scope, request)).result;
|
|
136
|
+
}
|
|
129
137
|
async function fireAndForget(path, scope) {
|
|
130
138
|
await exchange(path, scope.body, {
|
|
131
139
|
timeoutMs: SHORT_TIMEOUT_MS,
|
|
@@ -136,24 +144,32 @@ export function httpToolGate(options) {
|
|
|
136
144
|
recovers: true,
|
|
137
145
|
async openMcp(server) {
|
|
138
146
|
// The credential is read per request: the connection outlives the advance that opened it.
|
|
139
|
-
|
|
147
|
+
// A gate answers the server's instructions (R2b C10); an older one answers null.
|
|
148
|
+
const connected = await answered(MCP_CONNECT_PATH, scoped(server, (named) => ({ server: named })), {});
|
|
149
|
+
const instructions = typeof connected?.instructions === "string" && connected.instructions.length > 0
|
|
150
|
+
? connected.instructions
|
|
151
|
+
: undefined;
|
|
140
152
|
const client = {
|
|
141
153
|
listTools: (params, request) => answered(MCP_LIST_PATH, scoped(server, (named) => ({
|
|
142
154
|
server: named,
|
|
143
155
|
...(params?.cursor ? { cursor: params.cursor } : {}),
|
|
144
156
|
})), request?.signal ? { signal: request.signal } : {}),
|
|
145
|
-
callTool
|
|
146
|
-
server
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
async callTool(params, request) {
|
|
158
|
+
const called = await answer(TOOL_CALLS_PATH, scoped(server, (named) => ({
|
|
159
|
+
server: named,
|
|
160
|
+
effectId: request?.key ?? randomUUID(),
|
|
161
|
+
name: params.name,
|
|
162
|
+
arguments: params.arguments,
|
|
163
|
+
})), {
|
|
164
|
+
...(request?.signal ? { signal: request.signal } : {}),
|
|
165
|
+
...(request?.key ? { idempotencyKey: request.key } : {}),
|
|
166
|
+
});
|
|
167
|
+
return { result: called.result, ...(called.redacted ? { redacted: called.redacted } : {}) };
|
|
168
|
+
},
|
|
154
169
|
};
|
|
155
170
|
return {
|
|
156
171
|
client,
|
|
172
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
157
173
|
close: () => fireAndForget(MCP_CLOSE_PATH, scoped(server, (named) => ({ server: named }))),
|
|
158
174
|
};
|
|
159
175
|
},
|
|
@@ -174,6 +190,9 @@ export function httpToolGate(options) {
|
|
|
174
190
|
}
|
|
175
191
|
/** An MCP failure the gate reported, as the loop's own MCP code would have thrown it. */
|
|
176
192
|
function mcpError(error) {
|
|
193
|
+
// Coded (R2b C7): the model sees it. A call lost with the gateway never is.
|
|
194
|
+
if (error.failure && !error.uncertain)
|
|
195
|
+
return McpCallFailed.from(error.failure);
|
|
177
196
|
const thrown = new Error(error.uncertain ? `${error.message} (the call may have run; it is not sent again)` : error.message);
|
|
178
197
|
if (error.code !== undefined)
|
|
179
198
|
thrown.code = error.code;
|