@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 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
  }
@@ -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
- command: string;
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>;
@@ -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
- if (typeof rec.command !== "string" || rec.command.trim() === "") {
43
- throw new Error("downstream-command-invalid");
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
- export async function openDownstream(spec) {
104
- if (!PREFIX_RE.test(spec.prefix)) {
105
- throw new Error("downstream-prefix-invalid");
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
- const services = createBodyServices({
227
- stateDir: config.stateDir,
228
- policyFile: config.policyFile,
229
- recordSigner: signers.recordSigner,
230
- effectSigner: signers.effectSigner,
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: TOOL_META }));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@verax-ai/body",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "engines": {