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.
- package/.env.example +29 -2
- package/AGENT_SETUP.md +3 -1
- package/CHANGELOG.md +524 -0
- package/DOCUMENTATION.md +178 -43
- package/ONBOARDING.md +339 -0
- package/QUICKSTART.md +15 -2
- package/README.md +6 -2
- package/config/agents/README.md +2 -2
- package/config/agents/examples/ares.yaml +6 -10
- package/config/agents/examples/assistant.yaml +6 -6
- package/config/agents/examples/augustin.yaml +9 -15
- package/config/agents/examples/cartographers.yaml +6 -6
- package/config/agents/examples/council.yaml +7 -7
- package/config/agents/examples/critic.yaml +24 -24
- package/config/agents/examples/harness.yaml +4 -4
- package/config/agents/examples/image_production.yaml +37 -35
- package/config/agents/examples/librarian.yaml +4 -4
- package/config/agents/examples/patient_advocate.yaml +6 -10
- package/config/agents/examples/pipeline.yaml +20 -19
- package/config/agents/examples/research.yaml +36 -43
- package/config/agents/examples/scribe.yaml +31 -32
- package/config/agents/examples/scriptorium.yaml +6 -6
- package/config/agents/examples/structured_critic.yaml +82 -0
- package/config/agents/examples/style_council.yaml +9 -11
- package/config/agents/examples/syndicate.yaml +3 -5
- package/config/agents/examples/tutor.yaml +2 -2
- package/config/agents/examples/weather.yaml +2 -2
- package/config/agents/syndicate.schema.json +859 -13
- package/config/agents/syndicateSchema.yaml +122 -73
- package/config/agents/templates/README.md +1 -1
- package/config/agents/templates/account_memory.yaml +4 -4
- package/config/agents/templates/case_desk.yaml +6 -6
- package/config/agents/templates/conversational.yaml +2 -2
- package/config/agents/templates/draft_review.yaml +6 -6
- package/config/agents/templates/intake_extractor.yaml +64 -62
- package/config/agents/templates/research_brief.yaml +6 -6
- package/config/agents/templates/research_desk.yaml +6 -6
- package/config/agents/templates/review_panel.yaml +8 -8
- package/config/agents/templates/support_triage.yaml +30 -29
- package/config/agents/templates/systems_operator.yaml +24 -5
- package/db/migrations/0014_durable_runs.sql +31 -0
- package/dist/lib/a2a/app.d.ts +27 -0
- package/dist/lib/a2a/app.js +64 -5
- package/dist/lib/a2a/executor.d.ts +7 -1
- package/dist/lib/a2a/executor.js +43 -5
- package/dist/lib/a2a/oauthSetup.d.ts +106 -0
- package/dist/lib/a2a/oauthSetup.js +259 -0
- package/dist/lib/agentDialect.d.ts +76 -0
- package/dist/lib/agentDialect.js +151 -0
- package/dist/lib/compile.d.ts +42 -6
- package/dist/lib/compile.js +97 -45
- package/dist/lib/compileNative.d.ts +11 -3
- package/dist/lib/compileNative.js +20 -1
- package/dist/lib/dispatch.d.ts +10 -5
- package/dist/lib/dispatch.js +10 -5
- package/dist/lib/doctor.d.ts +110 -1
- package/dist/lib/doctor.js +205 -9
- package/dist/lib/loadSyndicate.d.ts +54 -2
- package/dist/lib/loadSyndicate.js +22 -2
- package/dist/lib/memory/namespace.d.ts +6 -5
- package/dist/lib/memory/namespace.js +6 -5
- package/dist/lib/memory/providers.js +3 -4
- package/dist/lib/models/capabilities.d.ts +25 -1
- package/dist/lib/models/capabilities.js +79 -1
- package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
- package/dist/lib/models/chatCompletionsAdapter.js +19 -5
- package/dist/lib/models/claudeAdapter.js +4 -0
- package/dist/lib/models/contract.d.ts +5 -2
- package/dist/lib/models/gateway.d.ts +2 -1
- package/dist/lib/models/gateway.js +4 -2
- package/dist/lib/models/gatewayAdapter.d.ts +7 -1
- package/dist/lib/models/gatewayAdapter.js +8 -0
- package/dist/lib/models/geminiAdapter.js +8 -2
- package/dist/lib/models/genaiMapping.d.ts +8 -4
- package/dist/lib/models/genaiMapping.js +23 -9
- package/dist/lib/models/gptAdapter.d.ts +4 -2
- package/dist/lib/models/gptAdapter.js +11 -4
- package/dist/lib/models/grokAdapter.d.ts +3 -1
- package/dist/lib/models/grokAdapter.js +11 -3
- package/dist/lib/models/kimiAdapter.d.ts +2 -1
- package/dist/lib/models/kimiAdapter.js +3 -3
- package/dist/lib/models/reasoning.d.ts +30 -2
- package/dist/lib/models/reasoning.js +56 -5
- package/dist/lib/observability/tracer.d.ts +8 -1
- package/dist/lib/observability/tracer.js +33 -5
- package/dist/lib/onboarding.d.ts +134 -0
- package/dist/lib/onboarding.js +539 -0
- package/dist/lib/runtime/approvals.d.ts +8 -0
- package/dist/lib/runtime/approvals.js +4 -3
- package/dist/lib/runtime/credentials.d.ts +6 -0
- package/dist/lib/runtime/native/agentLoop.d.ts +23 -3
- package/dist/lib/runtime/native/agentLoop.js +107 -11
- package/dist/lib/runtime/native/checkpoint.d.ts +171 -0
- package/dist/lib/runtime/native/checkpoint.js +359 -0
- package/dist/lib/runtime/native/delegate.d.ts +222 -18
- package/dist/lib/runtime/native/delegate.js +390 -39
- package/dist/lib/runtime/native/interrupts.d.ts +184 -2
- package/dist/lib/runtime/native/interrupts.js +391 -1
- package/dist/lib/runtime/native/request.d.ts +13 -3
- package/dist/lib/runtime/native/request.js +28 -4
- package/dist/lib/runtime/native/telemetry.js +10 -15
- package/dist/lib/runtime/questions.d.ts +7 -4
- package/dist/lib/runtime/sessions.d.ts +1 -1
- package/dist/lib/runtime/syndicateTurn.d.ts +11 -3
- package/dist/lib/runtime/syndicateTurn.js +353 -68
- package/dist/lib/runtime/turnControl.d.ts +56 -0
- package/dist/lib/runtime/turnControl.js +10 -0
- package/dist/lib/session/transcript.d.ts +9 -0
- package/dist/lib/session/transcript.js +20 -1
- package/dist/lib/skills.d.ts +18 -0
- package/dist/lib/skills.js +73 -1
- package/dist/lib/storage/postgres/index.d.ts +12 -2
- package/dist/lib/storage/postgres/index.js +8 -3
- package/dist/lib/storage/postgres/taskQueue.d.ts +20 -4
- package/dist/lib/storage/postgres/taskQueue.js +29 -5
- package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
- package/dist/lib/storage/postgres/taskStore.js +74 -8
- package/dist/lib/syndicateSchema.d.ts +316 -1
- package/dist/lib/syndicateSchema.js +203 -29
- package/dist/lib/tools/auth.d.ts +20 -6
- package/dist/lib/tools/auth.js +21 -5
- package/dist/lib/tools/credentialEnv.d.ts +28 -0
- package/dist/lib/tools/credentialEnv.js +52 -0
- package/dist/lib/tools/credentialHosts.d.ts +88 -0
- package/dist/lib/tools/credentialHosts.js +165 -0
- package/dist/lib/tools/credentialStore.js +9 -2
- package/dist/lib/tools/credentialUses.d.ts +92 -0
- package/dist/lib/tools/credentialUses.js +112 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
- package/dist/lib/tools/mcpToolFactory.js +179 -21
- package/dist/lib/tools/oauthConsent.js +8 -1
- package/dist/lib/tools/oauthHosts.d.ts +73 -0
- package/dist/lib/tools/oauthHosts.js +154 -0
- package/dist/lib/tools/oauthTools.d.ts +242 -0
- package/dist/lib/tools/oauthTools.js +447 -0
- package/dist/lib/tools/openapiTools.d.ts +11 -6
- package/dist/lib/tools/openapiTools.js +88 -38
- package/dist/lib/tools/taskTools.d.ts +61 -6
- package/dist/lib/tools/taskTools.js +145 -21
- package/dist/lib/workflow/turn.d.ts +22 -3
- package/dist/lib/workflow/turn.js +151 -31
- package/dist/lib/workflow.d.ts +14 -7
- package/dist/lib/workflow.js +14 -7
- package/dist/lib/workflowConfig.d.ts +6 -0
- package/dist/scripts/a2a_server.d.ts +36 -0
- package/dist/scripts/a2a_server.js +131 -1
- package/dist/scripts/assistant_worker.d.ts +12 -0
- package/dist/scripts/assistant_worker.js +81 -22
- package/dist/scripts/doctor.d.ts +7 -2
- package/dist/scripts/doctor.js +9 -4
- package/dist/scripts/setup.d.ts +23 -0
- package/dist/scripts/setup.js +154 -0
- package/dist/scripts/skills_install.d.ts +3 -1
- package/dist/scripts/skills_install.js +17 -2
- package/dist/scripts/yaml_codemod.d.ts +62 -0
- package/dist/scripts/yaml_codemod.js +652 -0
- package/package.json +8 -2
- package/skills/README.md +11 -2
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-author.md +7 -5
- package/skills/briefs/melchizedek-models.md +7 -4
- package/skills/briefs/melchizedek.md +1 -0
- package/skills/briefs/skills-readme.md +3 -1
- package/skills/melchizedek/SKILL.md +1 -0
- package/skills/melchizedek-author/SKILL.md +7 -4
- package/skills/melchizedek-author/assets/minimal.yaml +13 -13
- package/skills/melchizedek-models/SKILL.md +11 -4
- package/skills/melchizedek-onboard/SKILL.md +49 -0
- package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
- package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
- package/skills/melchizedek-onboard-local/SKILL.md +41 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|