melchizedek-agents 1.0.3 → 1.2.0

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 (172) hide show
  1. package/.env.example +29 -2
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +524 -0
  4. package/DOCUMENTATION.md +178 -43
  5. package/ONBOARDING.md +339 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +6 -2
  8. package/config/agents/README.md +2 -2
  9. package/config/agents/examples/ares.yaml +6 -10
  10. package/config/agents/examples/assistant.yaml +6 -6
  11. package/config/agents/examples/augustin.yaml +9 -15
  12. package/config/agents/examples/cartographers.yaml +6 -6
  13. package/config/agents/examples/council.yaml +7 -7
  14. package/config/agents/examples/critic.yaml +24 -24
  15. package/config/agents/examples/harness.yaml +4 -4
  16. package/config/agents/examples/image_production.yaml +37 -35
  17. package/config/agents/examples/librarian.yaml +4 -4
  18. package/config/agents/examples/patient_advocate.yaml +6 -10
  19. package/config/agents/examples/pipeline.yaml +20 -19
  20. package/config/agents/examples/research.yaml +36 -43
  21. package/config/agents/examples/scribe.yaml +31 -32
  22. package/config/agents/examples/scriptorium.yaml +6 -6
  23. package/config/agents/examples/structured_critic.yaml +82 -0
  24. package/config/agents/examples/style_council.yaml +9 -11
  25. package/config/agents/examples/syndicate.yaml +3 -5
  26. package/config/agents/examples/tutor.yaml +2 -2
  27. package/config/agents/examples/weather.yaml +2 -2
  28. package/config/agents/syndicate.schema.json +859 -13
  29. package/config/agents/syndicateSchema.yaml +122 -73
  30. package/config/agents/templates/README.md +1 -1
  31. package/config/agents/templates/account_memory.yaml +4 -4
  32. package/config/agents/templates/case_desk.yaml +6 -6
  33. package/config/agents/templates/conversational.yaml +2 -2
  34. package/config/agents/templates/draft_review.yaml +6 -6
  35. package/config/agents/templates/intake_extractor.yaml +64 -62
  36. package/config/agents/templates/research_brief.yaml +6 -6
  37. package/config/agents/templates/research_desk.yaml +6 -6
  38. package/config/agents/templates/review_panel.yaml +8 -8
  39. package/config/agents/templates/support_triage.yaml +30 -29
  40. package/config/agents/templates/systems_operator.yaml +24 -5
  41. package/db/migrations/0014_durable_runs.sql +31 -0
  42. package/dist/lib/a2a/app.d.ts +27 -0
  43. package/dist/lib/a2a/app.js +64 -5
  44. package/dist/lib/a2a/executor.d.ts +7 -1
  45. package/dist/lib/a2a/executor.js +43 -5
  46. package/dist/lib/a2a/oauthSetup.d.ts +106 -0
  47. package/dist/lib/a2a/oauthSetup.js +259 -0
  48. package/dist/lib/agentDialect.d.ts +76 -0
  49. package/dist/lib/agentDialect.js +151 -0
  50. package/dist/lib/compile.d.ts +42 -6
  51. package/dist/lib/compile.js +97 -45
  52. package/dist/lib/compileNative.d.ts +11 -3
  53. package/dist/lib/compileNative.js +20 -1
  54. package/dist/lib/dispatch.d.ts +10 -5
  55. package/dist/lib/dispatch.js +10 -5
  56. package/dist/lib/doctor.d.ts +110 -1
  57. package/dist/lib/doctor.js +205 -9
  58. package/dist/lib/loadSyndicate.d.ts +54 -2
  59. package/dist/lib/loadSyndicate.js +22 -2
  60. package/dist/lib/memory/namespace.d.ts +6 -5
  61. package/dist/lib/memory/namespace.js +6 -5
  62. package/dist/lib/memory/providers.js +3 -4
  63. package/dist/lib/models/capabilities.d.ts +25 -1
  64. package/dist/lib/models/capabilities.js +79 -1
  65. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  66. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  67. package/dist/lib/models/claudeAdapter.js +4 -0
  68. package/dist/lib/models/contract.d.ts +5 -2
  69. package/dist/lib/models/gateway.d.ts +2 -1
  70. package/dist/lib/models/gateway.js +4 -2
  71. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  72. package/dist/lib/models/gatewayAdapter.js +8 -0
  73. package/dist/lib/models/geminiAdapter.js +8 -2
  74. package/dist/lib/models/genaiMapping.d.ts +8 -4
  75. package/dist/lib/models/genaiMapping.js +23 -9
  76. package/dist/lib/models/gptAdapter.d.ts +4 -2
  77. package/dist/lib/models/gptAdapter.js +11 -4
  78. package/dist/lib/models/grokAdapter.d.ts +3 -1
  79. package/dist/lib/models/grokAdapter.js +11 -3
  80. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  81. package/dist/lib/models/kimiAdapter.js +3 -3
  82. package/dist/lib/models/reasoning.d.ts +30 -2
  83. package/dist/lib/models/reasoning.js +56 -5
  84. package/dist/lib/observability/tracer.d.ts +8 -1
  85. package/dist/lib/observability/tracer.js +33 -5
  86. package/dist/lib/onboarding.d.ts +134 -0
  87. package/dist/lib/onboarding.js +539 -0
  88. package/dist/lib/runtime/approvals.d.ts +8 -0
  89. package/dist/lib/runtime/approvals.js +4 -3
  90. package/dist/lib/runtime/credentials.d.ts +6 -0
  91. package/dist/lib/runtime/native/agentLoop.d.ts +23 -3
  92. package/dist/lib/runtime/native/agentLoop.js +107 -11
  93. package/dist/lib/runtime/native/checkpoint.d.ts +171 -0
  94. package/dist/lib/runtime/native/checkpoint.js +359 -0
  95. package/dist/lib/runtime/native/delegate.d.ts +222 -18
  96. package/dist/lib/runtime/native/delegate.js +390 -39
  97. package/dist/lib/runtime/native/interrupts.d.ts +184 -2
  98. package/dist/lib/runtime/native/interrupts.js +391 -1
  99. package/dist/lib/runtime/native/request.d.ts +13 -3
  100. package/dist/lib/runtime/native/request.js +28 -4
  101. package/dist/lib/runtime/native/telemetry.js +10 -15
  102. package/dist/lib/runtime/questions.d.ts +7 -4
  103. package/dist/lib/runtime/sessions.d.ts +1 -1
  104. package/dist/lib/runtime/syndicateTurn.d.ts +11 -3
  105. package/dist/lib/runtime/syndicateTurn.js +353 -68
  106. package/dist/lib/runtime/turnControl.d.ts +56 -0
  107. package/dist/lib/runtime/turnControl.js +10 -0
  108. package/dist/lib/session/transcript.d.ts +9 -0
  109. package/dist/lib/session/transcript.js +20 -1
  110. package/dist/lib/skills.d.ts +18 -0
  111. package/dist/lib/skills.js +73 -1
  112. package/dist/lib/storage/postgres/index.d.ts +12 -2
  113. package/dist/lib/storage/postgres/index.js +8 -3
  114. package/dist/lib/storage/postgres/taskQueue.d.ts +20 -4
  115. package/dist/lib/storage/postgres/taskQueue.js +29 -5
  116. package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
  117. package/dist/lib/storage/postgres/taskStore.js +74 -8
  118. package/dist/lib/syndicateSchema.d.ts +316 -1
  119. package/dist/lib/syndicateSchema.js +203 -29
  120. package/dist/lib/tools/auth.d.ts +20 -6
  121. package/dist/lib/tools/auth.js +21 -5
  122. package/dist/lib/tools/credentialEnv.d.ts +28 -0
  123. package/dist/lib/tools/credentialEnv.js +52 -0
  124. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  125. package/dist/lib/tools/credentialHosts.js +165 -0
  126. package/dist/lib/tools/credentialStore.js +9 -2
  127. package/dist/lib/tools/credentialUses.d.ts +92 -0
  128. package/dist/lib/tools/credentialUses.js +112 -0
  129. package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
  130. package/dist/lib/tools/mcpToolFactory.js +179 -21
  131. package/dist/lib/tools/oauthConsent.js +8 -1
  132. package/dist/lib/tools/oauthHosts.d.ts +73 -0
  133. package/dist/lib/tools/oauthHosts.js +154 -0
  134. package/dist/lib/tools/oauthTools.d.ts +242 -0
  135. package/dist/lib/tools/oauthTools.js +447 -0
  136. package/dist/lib/tools/openapiTools.d.ts +11 -6
  137. package/dist/lib/tools/openapiTools.js +88 -38
  138. package/dist/lib/tools/taskTools.d.ts +61 -6
  139. package/dist/lib/tools/taskTools.js +145 -21
  140. package/dist/lib/workflow/turn.d.ts +22 -3
  141. package/dist/lib/workflow/turn.js +151 -31
  142. package/dist/lib/workflow.d.ts +14 -7
  143. package/dist/lib/workflow.js +14 -7
  144. package/dist/lib/workflowConfig.d.ts +6 -0
  145. package/dist/scripts/a2a_server.d.ts +36 -0
  146. package/dist/scripts/a2a_server.js +131 -1
  147. package/dist/scripts/assistant_worker.d.ts +12 -0
  148. package/dist/scripts/assistant_worker.js +81 -22
  149. package/dist/scripts/doctor.d.ts +7 -2
  150. package/dist/scripts/doctor.js +9 -4
  151. package/dist/scripts/setup.d.ts +23 -0
  152. package/dist/scripts/setup.js +154 -0
  153. package/dist/scripts/skills_install.d.ts +3 -1
  154. package/dist/scripts/skills_install.js +17 -2
  155. package/dist/scripts/yaml_codemod.d.ts +62 -0
  156. package/dist/scripts/yaml_codemod.js +652 -0
  157. package/package.json +8 -2
  158. package/skills/README.md +11 -2
  159. package/skills/briefs/_shared.md +1 -1
  160. package/skills/briefs/melchizedek-author.md +7 -5
  161. package/skills/briefs/melchizedek-models.md +7 -4
  162. package/skills/briefs/melchizedek.md +1 -0
  163. package/skills/briefs/skills-readme.md +3 -1
  164. package/skills/melchizedek/SKILL.md +1 -0
  165. package/skills/melchizedek-author/SKILL.md +7 -4
  166. package/skills/melchizedek-author/assets/minimal.yaml +13 -13
  167. package/skills/melchizedek-models/SKILL.md +11 -4
  168. package/skills/melchizedek-onboard/SKILL.md +49 -0
  169. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  170. package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
  171. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  172. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -11,12 +11,20 @@
11
11
  * The server is an untrusted tool vendor (ADR 0041): its URL passes the
12
12
  * SSRF guard, a credential goes only to its exact host, and its
13
13
  * descriptions and results are cut to a bound.
14
+ *
15
+ * A server that takes an OAuth token declares it in YAML (`mcp_auth:
16
+ * { oauth2 }`, ADR 0112): the server's own token (client_credentials) on one
17
+ * shared connection, or each user's (authorization_code) on that user's own
18
+ * connection, read through `ctx.accessToken` so a user who has not granted
19
+ * it is asked (ADR 0085).
14
20
  */
15
21
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
16
22
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
17
23
  import { toContractJsonSchema } from '../models/schemaNormalize.js';
18
24
  import { checkHost } from '../net/addressGuard.js';
19
25
  import { fetchWithRedirectPolicy } from '../net/redirects.js';
26
+ import { ToolCredentialError } from './auth.js';
27
+ import { oauthCallProblem, oauthTokenSource } from './oauthTools.js';
20
28
  import { MAX_RESULT_CHARS } from './tool.js';
21
29
  import { toGeminiSchema } from './toolContract.js';
22
30
  // Security (SSRF): mcp_server_url can arrive from a registry-stored syndicate
@@ -117,6 +125,8 @@ const openTransports = new Set();
117
125
  export async function closeMcpConnections() {
118
126
  const all = [...openTransports];
119
127
  openTransports.clear();
128
+ for (const pool of userPools)
129
+ pool.clear();
120
130
  await Promise.all(all.map((t) => t.close().catch(() => { })));
121
131
  }
122
132
  /**
@@ -135,19 +145,74 @@ export function mcpToolParameters(inputSchema) {
135
145
  const required = Array.isArray(inputSchema?.required) ? inputSchema.required : [];
136
146
  return toContractJsonSchema(toGeminiSchema({ type: 'object', properties, required }));
137
147
  }
148
+ /** What the server says about one tool, as the contract declares it (description bounded). */
149
+ function declarationOf(tool) {
150
+ return {
151
+ name: tool.name,
152
+ description: bounded(tool.description || `MCP Tool: ${tool.name}`, MAX_MCP_DESCRIPTION_CHARS, 'description'),
153
+ parameters: mcpToolParameters(tool.inputSchema),
154
+ };
155
+ }
156
+ /** A callTool result's text parts, joined and bounded. */
157
+ function resultText(result) {
158
+ // MCP callTool returns { content: [{ type: 'text', text: '...' }] }
159
+ const content = (result?.content ?? []);
160
+ const texts = content.filter((c) => c.type === 'text').map((c) => c.text ?? '');
161
+ return bounded(texts.join('\n'), MAX_MCP_RESULT_CHARS, 'result');
162
+ }
163
+ /** A message with every occurrence of `secret` cut out: an SDK error can quote a request. */
164
+ const redact = (message, secret) => (secret ? message.split(secret).join('[redacted]') : message);
165
+ /**
166
+ * The fetch an MCP transport uses when the server takes an OAuth token
167
+ * (ADR 0112): every request (the SSE stream and each POST) carries the
168
+ * token `token()` returns now, and every redirect hop is checked, a hop off
169
+ * the server's origin losing the header (MCP_REDIRECTS).
170
+ */
171
+ function bearerFetch(oauth2, token) {
172
+ return async (input, init) => {
173
+ // Every request is checked where it actually goes (the stream, and the
174
+ // message endpoint the server names), before a token is attached (ADR 0114).
175
+ const destination = input instanceof Request ? input.url : String(input);
176
+ if (oauthCallProblem(oauth2, destination))
177
+ throw new ToolCredentialError('host_refused', oauth2.provider, 'oauth');
178
+ const headers = new Headers(init?.headers);
179
+ headers.set('Authorization', `Bearer ${await token(destination)}`);
180
+ return mcpFetch(input, { ...init, headers });
181
+ };
182
+ }
183
+ /** Per-user connections at most per authorization-code server; the least recently used is closed first. */
184
+ export const MAX_MCP_USER_CONNECTIONS = 64;
185
+ /** Every per-user pool, so closeMcpConnections can empty them. */
186
+ const userPools = new Set();
138
187
  /**
139
188
  * The tools a remote MCP server offers, as own Tools, or none when the
140
189
  * server cannot be reached or refuses (logged, never thrown). The
141
190
  * connection stays open for the tools' calls; closeMcpConnections ends it.
191
+ *
192
+ * With `oauth2` (ADR 0112):
193
+ * - client_credentials: one connection, every request carrying the
194
+ * server's own token for the provider (lib/tools/oauthTools.ts).
195
+ * - authorization_code: the token is each user's, so there is no
196
+ * connection at compile time. Each name in `tools` becomes a Tool whose
197
+ * call reads the run's user's token (`ctx.accessToken`, which asks for
198
+ * consent when the user has not granted it) and runs on that user's own
199
+ * connection. The parameters are the server's, learned from the first
200
+ * listing any user's connection makes; until then a call connects and
201
+ * asks the model to call again with them, rather than call blind.
202
+ * A missing variable or a refused URL fails the compile (thrown), naming
203
+ * the variable, never a value.
142
204
  */
143
- export async function loadMcpTools(mcpServerUrl) {
205
+ export async function loadMcpTools(mcpServerUrl, options = {}) {
206
+ const oauth2 = options.oauth2;
207
+ if (oauth2?.grant === 'authorization_code')
208
+ return userGrantMcpTools(mcpServerUrl, oauth2, options.tools);
209
+ const tokenSource = oauth2 ? oauthTokenSource(oauth2, `mcp ${mcpServerUrl}`, mcpServerUrl, { allowPrivate: process.env.ALLOW_PRIVATE_MCP === 'true' }) : undefined;
144
210
  let transport;
145
211
  try {
146
212
  const url = await assertSafeMcpUrl(mcpServerUrl);
147
- transport = new SSEClientTransport(url, {
148
- requestInit: { headers: mcpAuthHeaders(url) },
149
- fetch: mcpFetch,
150
- });
213
+ transport = new SSEClientTransport(url, tokenSource
214
+ ? { fetch: bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)) }
215
+ : { requestInit: { headers: mcpAuthHeaders(url) }, fetch: mcpFetch });
151
216
  const client = new Client({
152
217
  name: 'melchizedek-a2a-client',
153
218
  version: '1.0.0'
@@ -161,33 +226,24 @@ export async function loadMcpTools(mcpServerUrl) {
161
226
  return toolsResponse.tools.map((tool) => {
162
227
  // MCP servers describe tools in standard lowercase JSON Schema, which
163
228
  // is the contract's dialect; mcpToolParameters derives the parameters.
164
- const declaration = {
165
- name: tool.name,
166
- description: bounded(tool.description || `MCP Tool: ${tool.name}`, MAX_MCP_DESCRIPTION_CHARS, 'description'),
167
- parameters: mcpToolParameters(tool.inputSchema),
168
- };
229
+ const declaration = declarationOf(tool);
169
230
  return {
170
231
  name: tool.name,
171
232
  declaration: () => declaration,
172
233
  execute: async (input) => {
173
234
  try {
174
- const result = await client.callTool({
175
- name: tool.name,
176
- arguments: input
177
- });
178
- const content = result.content;
179
- const texts = content.filter(c => c.type === 'text').map(c => c.text ?? '');
180
- return bounded(texts.join('\n'), MAX_MCP_RESULT_CHARS, 'result');
235
+ return resultText(await client.callTool({ name: tool.name, arguments: input }));
181
236
  }
182
237
  catch (error) {
183
- return `[MCP ERROR] Tool ${tool.name} failed: ${error.message}`;
238
+ return `[MCP ERROR] Tool ${tool.name} failed: ${error instanceof ToolCredentialError ? error.message : error?.message}`;
184
239
  }
185
240
  },
186
241
  };
187
242
  });
188
243
  }
189
244
  catch (error) {
190
- console.warn(`[MCP] Failed to connect or load tools from ${mcpServerUrl}`, error);
245
+ // A token that cannot be had is reported by kind; the rest as before.
246
+ console.warn(`[MCP] Failed to connect or load tools from ${mcpServerUrl}`, error instanceof ToolCredentialError ? error.message : error);
191
247
  // A failed connect leaves the SSE stream's reconnect timer running; close
192
248
  // it, or every unreachable server keeps retrying for the process's life.
193
249
  if (transport)
@@ -196,7 +252,109 @@ export async function loadMcpTools(mcpServerUrl) {
196
252
  return [];
197
253
  }
198
254
  }
255
+ /** The tools of a server whose calls carry each user's own OAuth token (authorization_code). */
256
+ function userGrantMcpTools(mcpServerUrl, oauth2, names) {
257
+ const where = `mcp ${mcpServerUrl}`;
258
+ if (!names?.length)
259
+ throw new Error(`${where}: an authorization_code mcp_auth needs mcp_tools, the names this agent may use (no user's grant exists to list them at startup)`);
260
+ const source = oauthTokenSource(oauth2, where, mcpServerUrl);
261
+ const provider = oauth2.provider;
262
+ /** The server's declarations, from the first listing any user's connection made. */
263
+ const known = new Map();
264
+ let listed = false;
265
+ const pool = new Map();
266
+ userPools.add(pool);
267
+ const drop = (key, conn) => {
268
+ if (pool.get(key) === conn)
269
+ pool.delete(key);
270
+ if (!conn.transport)
271
+ return;
272
+ openTransports.delete(conn.transport);
273
+ void conn.transport.close().catch(() => { });
274
+ };
275
+ const connectionFor = (key, token) => {
276
+ const held = pool.get(key);
277
+ if (held) {
278
+ held.holder.token = token;
279
+ // Most recently used last.
280
+ pool.delete(key);
281
+ pool.set(key, held);
282
+ return held.ready;
283
+ }
284
+ const holder = { token };
285
+ const conn = {};
286
+ conn.holder = holder;
287
+ conn.ready = (async () => {
288
+ const url = await assertSafeMcpUrl(mcpServerUrl);
289
+ const transport = new SSEClientTransport(url, { fetch: bearerFetch(oauth2, async () => holder.token) });
290
+ conn.transport = transport;
291
+ const client = new Client({ name: 'melchizedek-a2a-client', version: '1.0.0' }, { capabilities: {} });
292
+ await client.connect(transport);
293
+ openTransports.add(transport);
294
+ if (!listed) {
295
+ const offered = await client.listTools();
296
+ for (const tool of offered.tools)
297
+ if (names.includes(tool.name))
298
+ known.set(tool.name, declarationOf(tool));
299
+ listed = true;
300
+ }
301
+ return client;
302
+ })();
303
+ conn.ready.catch(() => drop(key, conn));
304
+ pool.set(key, conn);
305
+ while (pool.size > MAX_MCP_USER_CONNECTIONS) {
306
+ const [oldestKey, oldest] = pool.entries().next().value;
307
+ drop(oldestKey, oldest);
308
+ }
309
+ return conn.ready;
310
+ };
311
+ return names.map((name) => {
312
+ const placeholder = {
313
+ name,
314
+ description: `${name}, a tool on the ${provider} MCP server. Its parameters are known once this person has connected ${provider}; until then, call it with no arguments to connect.`,
315
+ parameters: mcpToolParameters(undefined),
316
+ };
317
+ return {
318
+ name,
319
+ declaration: () => known.get(name) ?? placeholder,
320
+ execute: async (input, ctx) => {
321
+ let token;
322
+ try {
323
+ token = await source(ctx, mcpServerUrl);
324
+ }
325
+ catch (error) {
326
+ return `[MCP ERROR] Tool ${name}: ${error instanceof ToolCredentialError ? error.message : `the ${provider} authorization could not be read.`}`;
327
+ }
328
+ if (!ctx?.appName || !ctx?.userId)
329
+ return `[MCP ERROR] Tool ${name}: ${new ToolCredentialError('no_user', provider).message}`;
330
+ const key = `${ctx.appName}\0${ctx.userId}`;
331
+ const blind = !known.has(name);
332
+ let client;
333
+ try {
334
+ client = await connectionFor(key, token);
335
+ }
336
+ catch {
337
+ return `[MCP ERROR] Tool ${name}: the ${provider} MCP server could not be reached with this person's authorization.`;
338
+ }
339
+ if (!known.has(name))
340
+ return `[MCP ERROR] Tool ${name}: the ${provider} MCP server does not offer it.`;
341
+ if (blind)
342
+ return `Connected to ${provider}. ${name}'s parameters are now known: call it again with them.`;
343
+ try {
344
+ return resultText(await client.callTool({ name, arguments: input }));
345
+ }
346
+ catch (error) {
347
+ // The connection may be dead (or its token refused): the next call opens a fresh one.
348
+ const conn = pool.get(key);
349
+ if (conn)
350
+ drop(key, conn);
351
+ return `[MCP ERROR] Tool ${name} failed: ${redact(String(error?.message ?? error), token)}`;
352
+ }
353
+ },
354
+ };
355
+ });
356
+ }
199
357
  /** The server's tools, as the compiler lists them on an agent: loadMcpTools' own Tools. */
200
- export async function createMcpTools(mcpServerUrl) {
201
- return loadMcpTools(mcpServerUrl);
358
+ export async function createMcpTools(mcpServerUrl, options = {}) {
359
+ return loadMcpTools(mcpServerUrl, options);
202
360
  }
@@ -38,6 +38,7 @@
38
38
  import { createHash, randomBytes } from 'node:crypto';
39
39
  import { scopeHashOf } from '../observability/audit.js';
40
40
  import { PROVIDER_NAME } from './auth.js';
41
+ import { clientSecretEnvOf, credentialCallProblem } from './credentialHosts.js';
41
42
  /**
42
43
  * Flows in this process's memory, at most `max` of them: the oldest is
43
44
  * dropped first, and an expired one whenever another is put.
@@ -192,8 +193,14 @@ export function oauthConsent(options) {
192
193
  client_id: client.clientId,
193
194
  code_verifier: flow.codeVerifier,
194
195
  });
195
- if (client.clientSecret)
196
+ if (client.clientSecret) {
197
+ // The secret goes only to a host the operator binds its variable to (ADR 0122), checked now:
198
+ // the allowlist can change after boot. Refused, nothing is sent.
199
+ const secretEnv = clientSecretEnvOf(client);
200
+ if (secretEnv && credentialCallProblem(secretEnv, client.tokenUrl))
201
+ throw new ConsentError('exchange_failed', flow.provider);
196
202
  body.set('client_secret', client.clientSecret);
203
+ }
197
204
  let json;
198
205
  try {
199
206
  const res = await doFetch(client.tokenUrl, {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * lib/tools/oauthHosts.ts — the operator's OAuth host allowlist: which hosts
3
+ * each provider's tokens may be sent to (WS6-3d, ADR 0114).
4
+ *
5
+ * WHY this file exists:
6
+ * A YAML declares an OAuth grant beside the tool it protects (ADR 0112) and
7
+ * chooses the host that tool calls. The credential store keys a user's
8
+ * grant by app (the memory namespace), user and provider, so two syndicates
9
+ * in one namespace share their users' grants. Without a binding, a
10
+ * registry-stored YAML could name a provider another syndicate's users
11
+ * granted and point a tool at a host of its own, and receive their tokens.
12
+ * The binding is the operator's, never the YAML's: an environment variable
13
+ * or `createA2AApp({ oauthHosts })`.
14
+ *
15
+ * THE RULE (ADR 0114):
16
+ * - With no allowlist configured, an authorization_code grant (a user's
17
+ * token) is refused; a client_credentials grant (the server's own token,
18
+ * from a secret the operator supplies) is allowed, as `bearer_env` is.
19
+ * - With an allowlist configured, it is the whole list: every grant's
20
+ * provider must be on it, and every host its tokens or its client secret
21
+ * go to (the API or MCP server, the token endpoint, the authorization
22
+ * endpoint) must be one of that provider's hosts.
23
+ * - The check runs when a syndicate is loaded to be served, when its tools
24
+ * are compiled, and again before each call sends a token.
25
+ *
26
+ * THE FORMAT: `MELCHIZEDEK_OAUTH_HOSTS="tracker=api.tracker.example.com,auth.tracker.example.com;github=api.github.com,github.com"`.
27
+ * Entries are separated by `;` (or a newline), a provider from its hosts by
28
+ * `=`, hosts by `,`. A host is a hostname, `*.example.com` (any subdomain,
29
+ * not the apex), an IPv4 address, or a bracketed IPv6 address. Ports and
30
+ * schemes are not part of a host: the transport rule (https, or http to a
31
+ * loopback host) is checked separately (tokenTransportProblem).
32
+ *
33
+ * Nothing here reads or prints a secret: hosts and provider names only. No
34
+ * regular expression here can backtrack: labels are split, then each is
35
+ * matched by one bounded character class.
36
+ */
37
+ /** The environment variable the allowlist is read from when no option sets it. */
38
+ export declare const OAUTH_HOSTS_ENV = "MELCHIZEDEK_OAUTH_HOSTS";
39
+ /** Provider name → the hosts its tokens may be sent to. */
40
+ export type OAuthHostAllowlist = Readonly<Record<string, readonly string[]>>;
41
+ /** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
42
+ export declare function hostPatternProblem(pattern: string): string | null;
43
+ /**
44
+ * The allowlist, checked and normalized (hosts lowercased, duplicates
45
+ * dropped). Throws, naming the provider and the host, on anything malformed.
46
+ */
47
+ export declare function checkOAuthHosts(allowlist: Record<string, readonly string[]>, source?: string): Record<string, string[]>;
48
+ /** The allowlist from its text form (MELCHIZEDEK_OAUTH_HOSTS). Throws on a malformed entry. */
49
+ export declare function parseOAuthHosts(text: string): Record<string, string[]>;
50
+ /**
51
+ * Sets this process's allowlist (createA2AApp's `oauthHosts` does), which
52
+ * wins over the environment variable. `undefined` returns to the variable.
53
+ */
54
+ export declare function setOAuthHosts(allowlist: Record<string, readonly string[]> | undefined): void;
55
+ /**
56
+ * The allowlist in force: the one set by `setOAuthHosts`, else the
57
+ * environment variable's, else undefined (not configured). A malformed
58
+ * variable throws, so the first check refuses rather than allowing.
59
+ */
60
+ export declare function oauthHosts(env?: NodeJS.ProcessEnv): OAuthHostAllowlist | undefined;
61
+ /** True when `hostname` (as URL.hostname gives it) matches one of `patterns`. */
62
+ export declare function hostAllowed(patterns: readonly string[], hostname: string): boolean;
63
+ /** The grant fields the rule reads. */
64
+ export interface OAuthGrantRef {
65
+ provider: string;
66
+ grant: 'authorization_code' | 'client_credentials';
67
+ }
68
+ /**
69
+ * Why a token (or client secret) of this grant may not be sent to `url`,
70
+ * or null. `role` names the URL in the message (`server`, `token_url`, …).
71
+ * `allowlist` defaults to the one in force; pass `null` for "not configured".
72
+ */
73
+ export declare function oauthHostProblem(grant: OAuthGrantRef, url: string, role?: string, allowlist?: OAuthHostAllowlist | null | undefined): string | null;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * lib/tools/oauthHosts.ts — the operator's OAuth host allowlist: which hosts
3
+ * each provider's tokens may be sent to (WS6-3d, ADR 0114).
4
+ *
5
+ * WHY this file exists:
6
+ * A YAML declares an OAuth grant beside the tool it protects (ADR 0112) and
7
+ * chooses the host that tool calls. The credential store keys a user's
8
+ * grant by app (the memory namespace), user and provider, so two syndicates
9
+ * in one namespace share their users' grants. Without a binding, a
10
+ * registry-stored YAML could name a provider another syndicate's users
11
+ * granted and point a tool at a host of its own, and receive their tokens.
12
+ * The binding is the operator's, never the YAML's: an environment variable
13
+ * or `createA2AApp({ oauthHosts })`.
14
+ *
15
+ * THE RULE (ADR 0114):
16
+ * - With no allowlist configured, an authorization_code grant (a user's
17
+ * token) is refused; a client_credentials grant (the server's own token,
18
+ * from a secret the operator supplies) is allowed, as `bearer_env` is.
19
+ * - With an allowlist configured, it is the whole list: every grant's
20
+ * provider must be on it, and every host its tokens or its client secret
21
+ * go to (the API or MCP server, the token endpoint, the authorization
22
+ * endpoint) must be one of that provider's hosts.
23
+ * - The check runs when a syndicate is loaded to be served, when its tools
24
+ * are compiled, and again before each call sends a token.
25
+ *
26
+ * THE FORMAT: `MELCHIZEDEK_OAUTH_HOSTS="tracker=api.tracker.example.com,auth.tracker.example.com;github=api.github.com,github.com"`.
27
+ * Entries are separated by `;` (or a newline), a provider from its hosts by
28
+ * `=`, hosts by `,`. A host is a hostname, `*.example.com` (any subdomain,
29
+ * not the apex), an IPv4 address, or a bracketed IPv6 address. Ports and
30
+ * schemes are not part of a host: the transport rule (https, or http to a
31
+ * loopback host) is checked separately (tokenTransportProblem).
32
+ *
33
+ * Nothing here reads or prints a secret: hosts and provider names only. No
34
+ * regular expression here can backtrack: labels are split, then each is
35
+ * matched by one bounded character class.
36
+ */
37
+ import { PROVIDER_NAME } from './auth.js';
38
+ /** The environment variable the allowlist is read from when no option sets it. */
39
+ export const OAUTH_HOSTS_ENV = 'MELCHIZEDEK_OAUTH_HOSTS';
40
+ const LABEL = /^[a-z0-9-]{1,63}$/;
41
+ const IPV6 = /^\[[0-9a-f:.]{2,45}\]$/;
42
+ /** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
43
+ export function hostPatternProblem(pattern) {
44
+ if (pattern.length > 253)
45
+ return 'is longer than 253 characters';
46
+ if (IPV6.test(pattern))
47
+ return null;
48
+ const name = pattern.startsWith('*.') ? pattern.slice(2) : pattern;
49
+ const labels = name.split('.');
50
+ if (labels.length < 1 || labels.some((l) => !LABEL.test(l) || l.startsWith('-') || l.endsWith('-'))) {
51
+ return 'is not a hostname, *.domain, IPv4 or [IPv6] address (no scheme, port or path)';
52
+ }
53
+ if (pattern.startsWith('*.') && labels.length < 2)
54
+ return 'is a wildcard over a top-level domain';
55
+ return null;
56
+ }
57
+ /**
58
+ * The allowlist, checked and normalized (hosts lowercased, duplicates
59
+ * dropped). Throws, naming the provider and the host, on anything malformed.
60
+ */
61
+ export function checkOAuthHosts(allowlist, source = 'oauthHosts') {
62
+ if (!allowlist || typeof allowlist !== 'object' || Array.isArray(allowlist))
63
+ throw new Error(`${source} must map each provider to its hosts.`);
64
+ const out = {};
65
+ for (const [provider, hosts] of Object.entries(allowlist)) {
66
+ if (!PROVIDER_NAME.test(provider))
67
+ throw new Error(`${source}: provider "${provider.slice(0, 64)}" must match ${PROVIDER_NAME}.`);
68
+ if (!Array.isArray(hosts) || hosts.length === 0)
69
+ throw new Error(`${source}: provider "${provider}" needs at least one host.`);
70
+ const seen = new Set();
71
+ for (const raw of hosts) {
72
+ const host = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
73
+ const problem = host ? hostPatternProblem(host) : 'is empty';
74
+ if (problem)
75
+ throw new Error(`${source}: provider "${provider}": host "${String(raw).slice(0, 80)}" ${problem}.`);
76
+ seen.add(host);
77
+ }
78
+ out[provider] = [...seen];
79
+ }
80
+ return out;
81
+ }
82
+ /** The allowlist from its text form (MELCHIZEDEK_OAUTH_HOSTS). Throws on a malformed entry. */
83
+ export function parseOAuthHosts(text) {
84
+ const parsed = {};
85
+ for (const entry of text.split(/[;\n]/)) {
86
+ const trimmed = entry.trim();
87
+ if (!trimmed)
88
+ continue;
89
+ const eq = trimmed.indexOf('=');
90
+ if (eq <= 0)
91
+ throw new Error(`${OAUTH_HOSTS_ENV}: each entry is provider=host,host (got "${trimmed.slice(0, 80)}").`);
92
+ const provider = trimmed.slice(0, eq).trim();
93
+ if (Object.hasOwn(parsed, provider))
94
+ throw new Error(`${OAUTH_HOSTS_ENV}: provider "${provider.slice(0, 64)}" is listed twice.`);
95
+ parsed[provider] = trimmed.slice(eq + 1).split(',').map((h) => h.trim()).filter((h) => h !== '');
96
+ }
97
+ return checkOAuthHosts(parsed, OAUTH_HOSTS_ENV);
98
+ }
99
+ let configured;
100
+ let envCache;
101
+ /**
102
+ * Sets this process's allowlist (createA2AApp's `oauthHosts` does), which
103
+ * wins over the environment variable. `undefined` returns to the variable.
104
+ */
105
+ export function setOAuthHosts(allowlist) {
106
+ configured = allowlist === undefined ? undefined : checkOAuthHosts(allowlist);
107
+ }
108
+ /**
109
+ * The allowlist in force: the one set by `setOAuthHosts`, else the
110
+ * environment variable's, else undefined (not configured). A malformed
111
+ * variable throws, so the first check refuses rather than allowing.
112
+ */
113
+ export function oauthHosts(env = process.env) {
114
+ if (configured)
115
+ return configured;
116
+ const raw = env[OAUTH_HOSTS_ENV];
117
+ if (raw === undefined || raw.trim() === '')
118
+ return undefined;
119
+ if (envCache?.raw !== raw)
120
+ envCache = { raw, value: parseOAuthHosts(raw) };
121
+ return envCache.value;
122
+ }
123
+ /** True when `hostname` (as URL.hostname gives it) matches one of `patterns`. */
124
+ export function hostAllowed(patterns, hostname) {
125
+ const host = hostname.toLowerCase();
126
+ return patterns.some((p) => (p.startsWith('*.') ? host.length > p.length - 1 && host.endsWith(p.slice(1)) : host === p));
127
+ }
128
+ /**
129
+ * Why a token (or client secret) of this grant may not be sent to `url`,
130
+ * or null. `role` names the URL in the message (`server`, `token_url`, …).
131
+ * `allowlist` defaults to the one in force; pass `null` for "not configured".
132
+ */
133
+ export function oauthHostProblem(grant, url, role = 'server', allowlist = oauthHosts()) {
134
+ const list = allowlist ?? undefined;
135
+ if (!list) {
136
+ if (grant.grant === 'client_credentials')
137
+ return null;
138
+ return `provider "${grant.provider}": an authorization_code grant sends each user's own token, so the operator binds the provider to its hosts first (${OAUTH_HOSTS_ENV}="${grant.provider}=api.example.com,…", or createA2AApp's oauthHosts)`;
139
+ }
140
+ if (!Object.hasOwn(list, grant.provider)) {
141
+ return `provider "${grant.provider}" is not on the operator's OAuth host allowlist (${OAUTH_HOSTS_ENV} or createA2AApp's oauthHosts)`;
142
+ }
143
+ let hostname;
144
+ try {
145
+ hostname = new URL(url).hostname;
146
+ }
147
+ catch {
148
+ return `${role} '${url.slice(0, 200)}' is not a URL`;
149
+ }
150
+ const hosts = list[grant.provider];
151
+ if (hostAllowed(hosts, hostname))
152
+ return null;
153
+ return `provider "${grant.provider}" may send its tokens only to ${hosts.join(', ')}; ${hostname} (${role}) is not one of them`;
154
+ }