@verax-ai/body 0.1.2 → 0.1.3
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/README.md +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +2 -0
- package/dist/downstream.d.ts +22 -1
- package/dist/downstream.js +100 -9
- package/dist/server.js +90 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ Bearer token the configured issuer did not sign.
|
|
|
35
35
|
| `VERAX_POLICY_FILE` | yes | Policy the gate applies. `@verax-ai/proxy` ships `policy/default.json`, which denies what it does not name. |
|
|
36
36
|
| `VERAX_BIND` | no | `host:port` to listen on. Default `127.0.0.1:8787`; anything but loopback needs `VERAX_TLS_TERMINATED=1`. |
|
|
37
37
|
| `VERAX_INVENTORY_FILE` | no | Roster document the body serves. The format is `@verax-ai/inventory`. |
|
|
38
|
+
| `VERAX_DOWNSTREAM` | no | Path to a JSON document naming MCP servers to put behind this gate: one `{ prefix, command?, args?, cwd?, env?, url?, headers?, timeoutMs? }` or an array of them. Each child names exactly one way in — `command` to spawn it over stdio, or `url` to reach one already running over Streamable HTTP, with `headers` for its own bearer. A path and not the JSON itself, because the document may name a key in `env` or `headers`. Their tools are served as `prefix.childName` and need an exact policy rule under that name; a named child that cannot be opened stops the body. |
|
|
38
39
|
|
|
39
40
|
`verax doctor` names what is missing. A misconfigured body exits with code 78
|
|
40
41
|
before it listens.
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ export type BodyConfig = {
|
|
|
10
10
|
tlsTerminated: boolean;
|
|
11
11
|
/** Path to an inventory JSON file. Missing file is absence, not a fault. */
|
|
12
12
|
inventoryFile?: string | null;
|
|
13
|
+
/**
|
|
14
|
+
* Path to the `VERAX_DOWNSTREAM` document. A path, not the JSON itself: the
|
|
15
|
+
* document may name a key in `env`, and a secret does not belong in the
|
|
16
|
+
* process environment of every child the operator later starts. Unset means
|
|
17
|
+
* no downstream; a named file that cannot be attached stops the body.
|
|
18
|
+
*/
|
|
19
|
+
downstreamFile?: string | null;
|
|
13
20
|
};
|
|
14
21
|
export type ConfigResult = {
|
|
15
22
|
ok: true;
|
package/dist/config.js
CHANGED
|
@@ -43,6 +43,7 @@ export function loadConfig(env) {
|
|
|
43
43
|
return { ok: false, code: EX_CONFIG, reason: "missing VERAX_POLICY_FILE" };
|
|
44
44
|
}
|
|
45
45
|
const inventoryRaw = env.VERAX_INVENTORY_FILE?.trim() ?? "";
|
|
46
|
+
const downstreamRaw = env.VERAX_DOWNSTREAM?.trim() ?? "";
|
|
46
47
|
return {
|
|
47
48
|
ok: true,
|
|
48
49
|
value: {
|
|
@@ -55,6 +56,7 @@ export function loadConfig(env) {
|
|
|
55
56
|
policyFile,
|
|
56
57
|
tlsTerminated,
|
|
57
58
|
inventoryFile: inventoryRaw === "" ? null : inventoryRaw,
|
|
59
|
+
downstreamFile: downstreamRaw === "" ? null : downstreamRaw,
|
|
58
60
|
},
|
|
59
61
|
};
|
|
60
62
|
}
|
package/dist/downstream.d.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import type { Principal, ToolCall, ToolResult } from "@verax-ai/proxy";
|
|
2
2
|
export type DownstreamToolFn = (call: ToolCall, principal: Principal, ref?: string) => Promise<ToolResult>;
|
|
3
|
+
/**
|
|
4
|
+
* One child, reached one of two ways. `command` spawns it over stdio;
|
|
5
|
+
* `url` speaks Streamable HTTP to one already running. Exactly one of the
|
|
6
|
+
* two: a document naming both does not say which the operator meant, and a
|
|
7
|
+
* document naming neither says nothing at all.
|
|
8
|
+
*/
|
|
3
9
|
export type DownstreamSpec = {
|
|
4
10
|
prefix: string;
|
|
5
|
-
|
|
11
|
+
/** stdio: the process to start. */
|
|
12
|
+
command?: string;
|
|
6
13
|
args?: string[];
|
|
7
14
|
cwd?: string;
|
|
8
15
|
env?: Record<string, string>;
|
|
16
|
+
/** HTTP: the MCP endpoint of a server already running. */
|
|
17
|
+
url?: string;
|
|
18
|
+
/** HTTP: headers the operator sends to the child, such as its own bearer. */
|
|
19
|
+
headers?: Record<string, string>;
|
|
9
20
|
timeoutMs?: number;
|
|
10
21
|
};
|
|
11
22
|
export type DownstreamTool = {
|
|
12
23
|
name: string;
|
|
24
|
+
/** The child's own description, as `tools/list` gave it. */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** The child's own input schema, republished unchanged. */
|
|
27
|
+
inputSchema?: unknown;
|
|
13
28
|
fn: DownstreamToolFn;
|
|
14
29
|
};
|
|
15
30
|
export type DownstreamSession = {
|
|
@@ -25,4 +40,10 @@ export declare class DownstreamCallError extends Error {
|
|
|
25
40
|
export declare function prefixedName(prefix: string, childName: string): string;
|
|
26
41
|
export declare function extraToolNameOk(name: string): boolean;
|
|
27
42
|
export declare function parseDownstreamJson(raw: string): DownstreamSpec;
|
|
43
|
+
/**
|
|
44
|
+
* The `VERAX_DOWNSTREAM` document: one child, or an array of them. An empty
|
|
45
|
+
* array is a document that attaches nothing, which is not the same as no
|
|
46
|
+
* document at all — the operator wrote it, so it is honoured.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseDownstreamDocument(raw: string): DownstreamSpec[];
|
|
28
49
|
export declare function openDownstream(spec: DownstreamSpec): Promise<DownstreamSession>;
|
package/dist/downstream.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Readable } from "node:stream";
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
5
|
const PREFIX_RE = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
5
6
|
const CHILD_TOOL_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
|
|
6
7
|
/** Prefixed name a caller may put on extraTools: `prefix.childName`. */
|
|
@@ -39,10 +40,49 @@ export function parseDownstreamJson(raw) {
|
|
|
39
40
|
if (typeof rec.prefix !== "string" || !PREFIX_RE.test(rec.prefix)) {
|
|
40
41
|
throw new Error("downstream-prefix-invalid");
|
|
41
42
|
}
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const cmdVar = rec.command !== undefined;
|
|
44
|
+
const urlVar = rec.url !== undefined;
|
|
45
|
+
if (cmdVar && urlVar)
|
|
46
|
+
throw new Error("downstream-transport-ambiguous");
|
|
47
|
+
if (!cmdVar && !urlVar)
|
|
48
|
+
throw new Error("downstream-transport-missing");
|
|
49
|
+
const spec = { prefix: rec.prefix };
|
|
50
|
+
if (cmdVar) {
|
|
51
|
+
if (typeof rec.command !== "string" || rec.command.trim() === "") {
|
|
52
|
+
throw new Error("downstream-command-invalid");
|
|
53
|
+
}
|
|
54
|
+
spec.command = rec.command;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (typeof rec.url !== "string" || rec.url.trim() === "") {
|
|
58
|
+
throw new Error("downstream-url-invalid");
|
|
59
|
+
}
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = new URL(rec.url);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new Error("downstream-url-invalid");
|
|
66
|
+
}
|
|
67
|
+
// Only the two schemes the SDK transport speaks. A `file:` or `ftp:` URL
|
|
68
|
+
// would be read as a fetch target by something later, so it is refused here.
|
|
69
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
70
|
+
throw new Error("downstream-url-invalid");
|
|
71
|
+
}
|
|
72
|
+
spec.url = rec.url;
|
|
73
|
+
if (rec.headers !== undefined) {
|
|
74
|
+
if (rec.headers === null || typeof rec.headers !== "object" || Array.isArray(rec.headers)) {
|
|
75
|
+
throw new Error("downstream-headers-invalid");
|
|
76
|
+
}
|
|
77
|
+
const headers = {};
|
|
78
|
+
for (const [k, v] of Object.entries(rec.headers)) {
|
|
79
|
+
if (typeof v !== "string")
|
|
80
|
+
throw new Error("downstream-headers-invalid");
|
|
81
|
+
headers[k] = v;
|
|
82
|
+
}
|
|
83
|
+
spec.headers = headers;
|
|
84
|
+
}
|
|
44
85
|
}
|
|
45
|
-
const spec = { prefix: rec.prefix, command: rec.command };
|
|
46
86
|
if (rec.args !== undefined) {
|
|
47
87
|
if (!Array.isArray(rec.args) || rec.args.some((a) => typeof a !== "string")) {
|
|
48
88
|
throw new Error("downstream-args-invalid");
|
|
@@ -75,6 +115,32 @@ export function parseDownstreamJson(raw) {
|
|
|
75
115
|
}
|
|
76
116
|
return spec;
|
|
77
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The `VERAX_DOWNSTREAM` document: one child, or an array of them. An empty
|
|
120
|
+
* array is a document that attaches nothing, which is not the same as no
|
|
121
|
+
* document at all — the operator wrote it, so it is honoured.
|
|
122
|
+
*/
|
|
123
|
+
export function parseDownstreamDocument(raw) {
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error("downstream-json-invalid");
|
|
130
|
+
}
|
|
131
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
132
|
+
const specs = [];
|
|
133
|
+
const prefixes = new Set();
|
|
134
|
+
for (const item of items) {
|
|
135
|
+
const spec = parseDownstreamJson(JSON.stringify(item));
|
|
136
|
+
if (prefixes.has(spec.prefix)) {
|
|
137
|
+
throw new Error(`downstream-prefix-duplicate:${spec.prefix}`);
|
|
138
|
+
}
|
|
139
|
+
prefixes.add(spec.prefix);
|
|
140
|
+
specs.push(spec);
|
|
141
|
+
}
|
|
142
|
+
return specs;
|
|
143
|
+
}
|
|
78
144
|
// The SDK result is a union (current shape with an index signature, or the
|
|
79
145
|
// legacy { toolResult } shape), so it is narrowed here rather than trusted.
|
|
80
146
|
function asTextResult(raw) {
|
|
@@ -100,14 +166,11 @@ async function shut(client, transport) {
|
|
|
100
166
|
await client.close().catch(() => undefined);
|
|
101
167
|
await transport.close().catch(() => undefined);
|
|
102
168
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
if (spec.command.trim() === "") {
|
|
169
|
+
/** Spawns the child. Its stderr is drained so a chatty child cannot block it. */
|
|
170
|
+
function stdioTransport(spec) {
|
|
171
|
+
if (spec.command === undefined || spec.command.trim() === "") {
|
|
108
172
|
throw new Error("downstream-command-invalid");
|
|
109
173
|
}
|
|
110
|
-
const timeoutMs = spec.timeoutMs ?? 10_000;
|
|
111
174
|
// Safe inherit + operator overlay. Not process.env: that would copy
|
|
112
175
|
// VERAX_* tokens the body already holds.
|
|
113
176
|
const env = { ...getDefaultEnvironment(), ...(spec.env ?? {}) };
|
|
@@ -122,6 +185,32 @@ export async function openDownstream(spec) {
|
|
|
122
185
|
const stderr = transport.stderr;
|
|
123
186
|
if (stderr instanceof Readable)
|
|
124
187
|
stderr.resume();
|
|
188
|
+
return transport;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Talks to a server already running. The headers are the operator's, from the
|
|
192
|
+
* document — the body's own bearer is never forwarded, for the same
|
|
193
|
+
* confused-deputy reason the stdio child does not inherit `VERAX_*`.
|
|
194
|
+
*/
|
|
195
|
+
function httpTransport(spec) {
|
|
196
|
+
if (spec.url === undefined)
|
|
197
|
+
throw new Error("downstream-url-invalid");
|
|
198
|
+
return new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
199
|
+
...(spec.headers ? { requestInit: { headers: spec.headers } } : {}),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export async function openDownstream(spec) {
|
|
203
|
+
if (!PREFIX_RE.test(spec.prefix)) {
|
|
204
|
+
throw new Error("downstream-prefix-invalid");
|
|
205
|
+
}
|
|
206
|
+
if (spec.command !== undefined && spec.url !== undefined) {
|
|
207
|
+
throw new Error("downstream-transport-ambiguous");
|
|
208
|
+
}
|
|
209
|
+
if (spec.command === undefined && spec.url === undefined) {
|
|
210
|
+
throw new Error("downstream-transport-missing");
|
|
211
|
+
}
|
|
212
|
+
const timeoutMs = spec.timeoutMs ?? 10_000;
|
|
213
|
+
const transport = spec.url !== undefined ? httpTransport(spec) : stdioTransport(spec);
|
|
125
214
|
const client = new Client({ name: "verax-downstream", version: "0.0.0" });
|
|
126
215
|
let listed;
|
|
127
216
|
try {
|
|
@@ -148,6 +237,8 @@ export async function openDownstream(spec) {
|
|
|
148
237
|
const childName = tool.name;
|
|
149
238
|
tools.push({
|
|
150
239
|
name,
|
|
240
|
+
...(typeof tool.description === "string" ? { description: tool.description } : {}),
|
|
241
|
+
...(tool.inputSchema !== undefined ? { inputSchema: tool.inputSchema } : {}),
|
|
151
242
|
fn: async (call) => {
|
|
152
243
|
let raw;
|
|
153
244
|
try {
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createServer } from "node:http";
|
|
3
4
|
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
5
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
@@ -14,6 +15,7 @@ import { readHeartbeat, readWitnessPulse } from "./health-extras.js";
|
|
|
14
15
|
import { agentsWindow } from "./agents.js";
|
|
15
16
|
import { inventoryHealth, readInventoryFile } from "./inventory-file.js";
|
|
16
17
|
import { createBodyServices, TOOL_NAMES } from "./wiring.js";
|
|
18
|
+
import { openDownstream, parseDownstreamDocument, } from "./downstream.js";
|
|
17
19
|
// What a brain reads before it calls. Each description says what the tool is
|
|
18
20
|
// for, what it does and does not do, what the gate may answer, and what comes
|
|
19
21
|
// back; each parameter says its format and its bounds. The answers named here
|
|
@@ -221,17 +223,94 @@ function send(res, status, body, headers) {
|
|
|
221
223
|
});
|
|
222
224
|
res.end(text);
|
|
223
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Republishes a child's own `tools/list` entry under its prefixed name. The
|
|
228
|
+
* description is the child's; the sentence added here says what changes by
|
|
229
|
+
* going through the body, because the answers a caller gets back
|
|
230
|
+
* (`denied:…`, `deferred:…`) are the gate's, not the child's.
|
|
231
|
+
*/
|
|
232
|
+
function downstreamMeta(tool) {
|
|
233
|
+
const own = typeof tool.description === "string" && tool.description !== "" ? `${tool.description} ` : "";
|
|
234
|
+
return {
|
|
235
|
+
name: tool.name,
|
|
236
|
+
description: own +
|
|
237
|
+
"Forwarded by this body to the downstream server that published it: the call passes the same policy gate " +
|
|
238
|
+
"and leaves the same signed decision under this name, so it may be answered with denied:<reason>:<ref> or " +
|
|
239
|
+
"deferred:approval-required:<ref> before the downstream server ever sees it.",
|
|
240
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
async function closeAll(sessions) {
|
|
244
|
+
for (const session of sessions) {
|
|
245
|
+
await session.close().catch(() => undefined);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* What an operator may see about the attached children: the prefix they call
|
|
250
|
+
* by, how the body reaches them, and the names it will accept.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately not the address. A child's URL can carry its token in the path
|
|
253
|
+
* — the live Conarium's does — and a command line names a path on this host.
|
|
254
|
+
* Neither is needed to answer "what is standing behind this gate", so neither
|
|
255
|
+
* is published. The `tests/downstream-visible` guard asserts their absence.
|
|
256
|
+
*/
|
|
257
|
+
function downstreamPublic(sessions, specs) {
|
|
258
|
+
return sessions.map((session) => {
|
|
259
|
+
const spec = specs.find((s) => s.prefix === session.prefix);
|
|
260
|
+
return {
|
|
261
|
+
prefix: session.prefix,
|
|
262
|
+
transport: spec?.url !== undefined ? "http" : "stdio",
|
|
263
|
+
tools: session.tools.map((t) => t.name),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Opens every child the document names. One child that refuses to attach
|
|
269
|
+
* closes the ones already open and throws: a half-attached body would serve a
|
|
270
|
+
* tool list its operator never wrote.
|
|
271
|
+
*/
|
|
272
|
+
async function attachDownstream(file) {
|
|
273
|
+
if (file === null || file.trim() === "")
|
|
274
|
+
return { sessions: [], specs: [] };
|
|
275
|
+
const specs = parseDownstreamDocument(readFileSync(file, "utf8"));
|
|
276
|
+
const sessions = [];
|
|
277
|
+
for (const spec of specs) {
|
|
278
|
+
try {
|
|
279
|
+
sessions.push(await openDownstream(spec));
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
await closeAll(sessions);
|
|
283
|
+
const detail = err instanceof Error ? err.message : "attach-failed";
|
|
284
|
+
throw new Error(`downstream-attach-failed:${spec.prefix}:${detail}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { sessions, specs };
|
|
288
|
+
}
|
|
224
289
|
export async function listen(config) {
|
|
225
290
|
const signers = loadOrCreateSigners(config.stateDir);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
291
|
+
// The children are attached before the door opens. A named document the body
|
|
292
|
+
// cannot honour stops the start: a body that serves six tools while its
|
|
293
|
+
// operator wrote seven is answering for a gate it does not have.
|
|
294
|
+
const { sessions, specs: downstreamSpecs } = await attachDownstream(config.downstreamFile ?? null);
|
|
295
|
+
const extraTools = sessions.flatMap((session) => session.tools);
|
|
296
|
+
let services;
|
|
297
|
+
try {
|
|
298
|
+
services = createBodyServices({
|
|
299
|
+
stateDir: config.stateDir,
|
|
300
|
+
policyFile: config.policyFile,
|
|
301
|
+
recordSigner: signers.recordSigner,
|
|
302
|
+
effectSigner: signers.effectSigner,
|
|
303
|
+
...(extraTools.length > 0 ? { extraTools } : {}),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
await closeAll(sessions);
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
const toolMeta = [...TOOL_META, ...extraTools.map(downstreamMeta)];
|
|
232
311
|
const verify = createVerifier(config.jwksUrl, config.issuer, config.audience);
|
|
233
312
|
const attachHandlers = (mcp) => {
|
|
234
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools:
|
|
313
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolMeta }));
|
|
235
314
|
mcp.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
236
315
|
const auth = extra?.authInfo;
|
|
237
316
|
const scopes = new Set(auth?.scopes ?? []);
|
|
@@ -315,6 +394,9 @@ export async function listen(config) {
|
|
|
315
394
|
heartbeat: readHeartbeat(config.stateDir),
|
|
316
395
|
witness: readWitnessPulse(config.stateDir),
|
|
317
396
|
inventory: inventoryHealth(readInventoryFile(config.inventoryFile)),
|
|
397
|
+
// Always an array, empty when nothing is attached: a missing field
|
|
398
|
+
// would read as "this body is too old to tell you".
|
|
399
|
+
downstream: downstreamPublic(sessions, downstreamSpecs),
|
|
318
400
|
});
|
|
319
401
|
return;
|
|
320
402
|
}
|
|
@@ -620,6 +702,7 @@ export async function listen(config) {
|
|
|
620
702
|
});
|
|
621
703
|
server.on("close", () => {
|
|
622
704
|
services.ledger.close();
|
|
705
|
+
void closeAll(sessions);
|
|
623
706
|
});
|
|
624
707
|
return server;
|
|
625
708
|
}
|