melchizedek-agents 1.2.0 → 1.3.1

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 (66) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +174 -0
  3. package/DOCUMENTATION.md +107 -10
  4. package/ONBOARDING.md +50 -11
  5. package/QUICKSTART.md +1 -1
  6. package/README.md +22 -2
  7. package/config/agents/examples/connectors.yaml +82 -0
  8. package/config/agents/syndicate.schema.json +324 -26
  9. package/dist/lib/a2a/app.js +3 -0
  10. package/dist/lib/a2a/oauthSetup.js +20 -5
  11. package/dist/lib/chatgpt/adapter.d.ts +50 -0
  12. package/dist/lib/chatgpt/adapter.js +107 -0
  13. package/dist/lib/chatgpt/cli.d.ts +23 -0
  14. package/dist/lib/chatgpt/cli.js +77 -0
  15. package/dist/lib/chatgpt/oauth.d.ts +104 -0
  16. package/dist/lib/chatgpt/oauth.js +472 -0
  17. package/dist/lib/chatgpt/state.d.ts +98 -0
  18. package/dist/lib/chatgpt/state.js +145 -0
  19. package/dist/lib/chatgpt/store.d.ts +24 -0
  20. package/dist/lib/chatgpt/store.js +94 -0
  21. package/dist/lib/compile.js +40 -25
  22. package/dist/lib/dispatch.js +6 -2
  23. package/dist/lib/doctor.d.ts +34 -3
  24. package/dist/lib/doctor.js +63 -5
  25. package/dist/lib/loadSyndicate.d.ts +6 -0
  26. package/dist/lib/mcp/server.d.ts +195 -0
  27. package/dist/lib/mcp/server.js +564 -0
  28. package/dist/lib/memory/supabaseMemoryService.js +2 -2
  29. package/dist/lib/models/adapterResolver.js +6 -1
  30. package/dist/lib/models/gateway.d.ts +1 -1
  31. package/dist/lib/models/gateway.js +4 -2
  32. package/dist/lib/models/gptAdapter.js +11 -1
  33. package/dist/lib/onboarding.d.ts +4 -3
  34. package/dist/lib/onboarding.js +48 -9
  35. package/dist/lib/runtime/syndicateTurn.js +30 -16
  36. package/dist/lib/syndicateSchema.d.ts +146 -8
  37. package/dist/lib/syndicateSchema.js +118 -11
  38. package/dist/lib/tools/credentialUses.d.ts +7 -1
  39. package/dist/lib/tools/credentialUses.js +10 -2
  40. package/dist/lib/tools/generateImageTool.js +2 -1
  41. package/dist/lib/tools/inspectImageTool.js +1 -1
  42. package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
  43. package/dist/lib/tools/mcpToolFactory.js +71 -25
  44. package/dist/lib/tools/mcpTransports.d.ts +12 -0
  45. package/dist/lib/tools/mcpTransports.js +11 -0
  46. package/dist/lib/tools/oauthConsent.d.ts +18 -3
  47. package/dist/lib/tools/oauthConsent.js +49 -14
  48. package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
  49. package/dist/lib/tools/oauthDiscovery.js +616 -0
  50. package/dist/lib/tools/oauthTools.d.ts +44 -12
  51. package/dist/lib/tools/oauthTools.js +79 -22
  52. package/dist/lib/tools/xApiSearchTool.js +2 -2
  53. package/dist/scripts/a2a_server.js +8 -0
  54. package/dist/scripts/assistant_worker.js +11 -1
  55. package/dist/scripts/doctor.d.ts +1 -1
  56. package/dist/scripts/doctor.js +1 -1
  57. package/dist/scripts/mcp_server.d.ts +12 -0
  58. package/dist/scripts/mcp_server.js +345 -0
  59. package/dist/scripts/setup.d.ts +2 -0
  60. package/dist/scripts/setup.js +27 -0
  61. package/dist/scripts/syndicate_chat.js +12 -3
  62. package/package.json +15 -12
  63. package/skills/README.md +2 -2
  64. package/skills/briefs/skills-readme.md +1 -1
  65. package/skills/melchizedek-onboard/SKILL.md +28 -6
  66. package/skills/melchizedek-onboard-keys/SKILL.md +17 -5
@@ -0,0 +1,564 @@
1
+ /**
2
+ * lib/mcp/server.ts — syndicates served as MCP tools (ADR 0125).
3
+ *
4
+ * `createMcpServer(options)` makes each loaded syndicate one MCP tool, so an
5
+ * MCP client (Claude Code, Codex, a custom connector) asks a syndicate the way
6
+ * it calls any other tool: `{ message, session_id? }` in, the turn's answer
7
+ * out, with the session id it can pass back to continue the conversation.
8
+ * One more tool, `melch_resume`, answers a turn that paused: an approval
9
+ * (`adk_request_confirmation`), a workflow question (`ask_user`), or an OAuth
10
+ * consent.
11
+ *
12
+ * WHY it drives the A2A executor: every turn semantic the A2A server applies
13
+ * (budgets, the concurrency caps, one turn at a time per conversation, the
14
+ * approval answer read from the stored events, the deadline, cancellation,
15
+ * guards and the ledger inside `runSyndicateTurn`, the task record and audit
16
+ * event, memory ingestion after the reply) lives in `SyndicateExecutor`
17
+ * (lib/a2a/executor.ts). This module hands it one task per tool call through
18
+ * a collecting event bus and reads the final status back, so the MCP surface
19
+ * cannot drift from the A2A one. Nothing here calls the turn runner itself.
20
+ *
21
+ * Transports: `serveMcpStdio` (one local client over stdin/stdout) and
22
+ * `mcpHttpApp` (Streamable HTTP, stateful sessions, behind the bearer rule
23
+ * the A2A server applies). `mcpBindProblem` is the refusal the bin applies
24
+ * before listening: a bind beyond loopback without a secret never starts.
25
+ *
26
+ * Logging: the executor's lines (task ids by prefix, tool names, counts) and
27
+ * this module's own (tool names, session ids by prefix). A user's message,
28
+ * an answer, a secret or a model key is never logged here.
29
+ */
30
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
31
+ import express from 'express';
32
+ import rateLimit from 'express-rate-limit';
33
+ import { TaskState } from '@a2a-js/sdk';
34
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
35
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
36
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
37
+ import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
38
+ import { CallToolRequestSchema, ListToolsRequestSchema, isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
39
+ import { resolveModel } from '../models/registry.js';
40
+ import { InProcessSessionService } from '../runtime/sessions.js';
41
+ import { namespacedMemoryService } from '../memory/namespace.js';
42
+ import { A2A_APP_NAME, SyndicateExecutor, TaskLimiter, requestContextStorage } from '../a2a/executor.js';
43
+ import { HEADER_VALUE_PATTERN } from '../a2a/identity.js';
44
+ import { inProcessTurnLock } from '../a2a/turnLock.js';
45
+ /** The tool that answers a paused turn. Never a syndicate's tool name. */
46
+ export const RESUME_TOOL = 'melch_resume';
47
+ /** MCP tool names (Claude Code and Codex accept these): letters, digits, `_` and `-`, at most 64. */
48
+ const TOOL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
49
+ /** A session id a caller hands back: what this server issues, and A2A context ids. Linear, no nesting. */
50
+ export const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
51
+ /** The longest message a tool call carries. */
52
+ const MAX_MESSAGE_CHARS = 100_000;
53
+ /** A tool description is prompt text for the calling model: kept short. */
54
+ const MAX_DESCRIPTION_CHARS = 1_000;
55
+ /** Sessions whose pause and syndicate this process remembers. */
56
+ const MAX_REMEMBERED_SESSIONS = 10_000;
57
+ /** The stdio caller: this machine's own user. */
58
+ export const LOCAL_CALLER = { scopeKey: 'default', caller: 'local' };
59
+ /** A syndicate's tool name from its file id: anything outside [A-Za-z0-9_-] becomes `_`. */
60
+ export function toolNameFor(fileId) {
61
+ const base = fileId.replace(/\.ya?ml$/i, '');
62
+ const name = base.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
63
+ return name || 'syndicate';
64
+ }
65
+ /** The text after a tool's answer that hands its session back. */
66
+ function sessionLine(sessionId) {
67
+ return `[session_id: ${sessionId}] Pass this session_id to continue this conversation.`;
68
+ }
69
+ function describePause(p, sessionId) {
70
+ switch (p.kind) {
71
+ case 'approval': {
72
+ const args = JSON.stringify(p.args ?? {});
73
+ const summary = args.length > 600 ? `${args.slice(0, 600)}…` : args;
74
+ const via = p.path?.length ? ` (inside ${p.path.join(' → ')})` : '';
75
+ return `Approval needed: ${p.agent}${via} wants to run ${p.tool}(${summary}). Nothing has run. `
76
+ + `Ask the person, then call ${RESUME_TOOL} with session_id "${sessionId}" and approve: true to run it or false to refuse it.`;
77
+ }
78
+ case 'input': {
79
+ const choices = Array.isArray(p.options) && p.options.length ? ` (${p.options.join(' / ')})` : '';
80
+ return `Input needed: ${p.node} asks: ${p.message || '(no question text)'}${choices}. `
81
+ + `Call ${RESUME_TOOL} with session_id "${sessionId}" and answer: "<the person's answer>".`;
82
+ }
83
+ case 'consent':
84
+ return `Authorization needed: ${p.agent} needs access to the person's ${p.provider} account. `
85
+ + `The person opens ${p.authorizationUrl} to authorize; then call ${RESUME_TOOL} with session_id "${sessionId}" to continue.`;
86
+ }
87
+ }
88
+ /** Reads the executor's events: the last final status, and the pause its data part names. */
89
+ function collectingBus(onWorking) {
90
+ let state;
91
+ let text = '';
92
+ let data;
93
+ const bus = {
94
+ publish(event) {
95
+ if (event?.kind !== 'statusUpdate')
96
+ return;
97
+ const status = event.data?.status;
98
+ const parts = status?.message?.parts ?? [];
99
+ const message = parts
100
+ .filter((p) => p?.content?.$case === 'text')
101
+ .map((p) => String(p.content.value))
102
+ .join('');
103
+ if (status?.state === TaskState.TASK_STATE_WORKING) {
104
+ if (message)
105
+ onWorking(message.replace(/^\[STATUS\] /, ''));
106
+ return;
107
+ }
108
+ state = status?.state;
109
+ text = message;
110
+ data = parts.find((p) => p?.content?.$case === 'data')?.content?.value;
111
+ },
112
+ on() { return bus; },
113
+ off() { return bus; },
114
+ once() { return bus; },
115
+ removeAllListeners() { return bus; },
116
+ finished() { },
117
+ };
118
+ return { bus: bus, result: () => ({ state, text, ...(data ? { data } : {}) }) };
119
+ }
120
+ function pauseFrom(data) {
121
+ if (!data || typeof data !== 'object')
122
+ return undefined;
123
+ if (data.type === 'approval_request') {
124
+ return { kind: 'approval', id: String(data.approval_id), agent: String(data.agent ?? ''), tool: String(data.tool ?? ''), args: data.args ?? {}, ...(Array.isArray(data.path) ? { path: data.path.map(String) } : {}) };
125
+ }
126
+ if (data.type === 'input_request') {
127
+ const options = data.payload?.options;
128
+ return { kind: 'input', id: String(data.interrupt_id), node: String(data.node ?? ''), message: String(data.message ?? ''), ...(Array.isArray(options) ? { options } : {}) };
129
+ }
130
+ if (data.type === 'consent_request') {
131
+ return { kind: 'consent', id: String(data.consent_id), agent: String(data.agent ?? ''), provider: String(data.provider ?? ''), authorizationUrl: String(data.authorization_url ?? ''), ...(data.scopes !== undefined ? { scopes: data.scopes } : {}) };
132
+ }
133
+ return undefined;
134
+ }
135
+ /** Whether the syndicate's answer is one JSON object by declaration (an output schema or JSON mode). */
136
+ function declaresStructuredOutput(cfg) {
137
+ const o = cfg.orchestrator;
138
+ return !!(o?.output?.schema || o?.output?.mime === 'application/json' || o?.outputSchema);
139
+ }
140
+ const textResult = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
141
+ export function createMcpServer(options) {
142
+ const log = options.log ?? ((m) => console.error(`[MCP] ${m}`));
143
+ const warn = options.warn ?? ((m) => console.error(`[MCP] ⚠ ${m}`));
144
+ const name = options.name ?? 'melchizedek';
145
+ const version = options.version ?? '1.0.0';
146
+ // ── The exposure: validated once, at construction ──────────────────────────
147
+ const byName = new Map();
148
+ for (const s of options.syndicates) {
149
+ if (!TOOL_NAME_PATTERN.test(s.id))
150
+ throw new Error(`MCP tool name '${s.id}' must match [A-Za-z0-9_-]{1,64}.`);
151
+ if (s.id === RESUME_TOOL)
152
+ throw new Error(`'${RESUME_TOOL}' is reserved for resuming a paused turn; rename the syndicate file.`);
153
+ if (byName.has(s.id))
154
+ throw new Error(`Two syndicates would both be the MCP tool '${s.id}'.`);
155
+ byName.set(s.id, s);
156
+ }
157
+ // ── The executor's collaborators, as createA2AApp builds them ──────────────
158
+ const internalSessions = new InProcessSessionService();
159
+ const durableSessions = options.sessionService;
160
+ const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0, {
161
+ perScope: options.maxConcurrentPerScope ?? 4,
162
+ perCaller: options.maxConcurrentPerCaller ?? 0,
163
+ });
164
+ const turnLock = options.turnLock ?? inProcessTurnLock();
165
+ const resolver = options.resolveModel ?? ((modelName) => resolveModel(modelName));
166
+ const compileFor = (_ctx) => ({
167
+ resolveModel: resolver,
168
+ onUnknownTool: (tool) => warn(`Unknown tool '${tool}' — skipping.`),
169
+ log,
170
+ });
171
+ const executors = new Map();
172
+ const executorFor = (s) => {
173
+ let ex = executors.get(s.id);
174
+ if (ex)
175
+ return ex;
176
+ const cfg = s.config;
177
+ const mode = cfg.memory_system;
178
+ const sessionService = mode === 'internal-only' || !durableSessions ? internalSessions : durableSessions;
179
+ const memoryService = mode === 'long-term' && options.memoryService
180
+ ? namespacedMemoryService(options.memoryService, cfg.memory_namespace || A2A_APP_NAME)
181
+ : undefined;
182
+ ex = new SyndicateExecutor({
183
+ config: cfg,
184
+ sessionService,
185
+ ...(memoryService ? { memoryService } : {}),
186
+ compileFor,
187
+ taskTimeoutMs: options.taskTimeoutMs,
188
+ limiter,
189
+ agentId: s.id,
190
+ turnLock,
191
+ turnLockWaitMs: options.turnLockWaitMs,
192
+ ...(options.toolCredentials ? { toolCredentials: options.toolCredentials } : {}),
193
+ ...(options.policy ? { policy: options.policy } : {}),
194
+ ...(options.onTaskEnd ? { onTaskEnd: options.onTaskEnd } : {}),
195
+ ...(options.audit ? { onAudit: options.audit } : {}),
196
+ log,
197
+ warn,
198
+ });
199
+ executors.set(s.id, ex);
200
+ return ex;
201
+ };
202
+ // ── What this process remembers of a session: its syndicate and open pause ─
203
+ const sessions = new Map();
204
+ const sessionKey = (caller, sessionId) => `${caller.scopeKey}\u0000${sessionId}`;
205
+ const remember = (key, value) => {
206
+ sessions.delete(key);
207
+ sessions.set(key, value);
208
+ while (sessions.size > MAX_REMEMBERED_SESSIONS)
209
+ sessions.delete(sessions.keys().next().value);
210
+ };
211
+ /** One task through the executor: what A2A's message/send does, minus the HTTP. */
212
+ const runTask = async (caller, syndicate, sessionId, parts, extra) => {
213
+ const executor = executorFor(syndicate);
214
+ const taskId = randomUUID();
215
+ const { bus, result } = collectingBus((t) => extra.onProgress?.(t));
216
+ const requestContext = {
217
+ taskId,
218
+ contextId: sessionId,
219
+ userMessage: { kind: 'message', messageId: randomUUID(), role: 'user', parts, contextId: sessionId, taskId },
220
+ };
221
+ const ctx = {
222
+ apiKey: '',
223
+ provider: 'google',
224
+ scopeKey: caller.scopeKey,
225
+ caller: caller.caller,
226
+ operator: caller.caller !== 'local',
227
+ ...(caller.sourceIp ? { sourceIp: caller.sourceIp } : {}),
228
+ surface: { name: 'mcp' },
229
+ };
230
+ // MCP's notifications/cancelled aborts the request's signal: that is the task's cancel.
231
+ const onAbort = () => void executor.cancelTask(taskId);
232
+ if (extra.signal?.aborted)
233
+ onAbort();
234
+ extra.signal?.addEventListener('abort', onAbort, { once: true });
235
+ try {
236
+ await requestContextStorage.run(ctx, () => executor.execute(requestContext, bus));
237
+ }
238
+ finally {
239
+ extra.signal?.removeEventListener('abort', onAbort);
240
+ }
241
+ const r = result();
242
+ const pause = r.state === TaskState.TASK_STATE_INPUT_REQUIRED ? pauseFrom(r.data) : undefined;
243
+ const status = r.state === TaskState.TASK_STATE_COMPLETED ? 'completed'
244
+ : r.state === TaskState.TASK_STATE_INPUT_REQUIRED ? 'input-required'
245
+ : r.state === TaskState.TASK_STATE_CANCELED ? 'canceled'
246
+ : r.state === TaskState.TASK_STATE_REJECTED ? 'rejected'
247
+ : 'failed';
248
+ remember(sessionKey(caller, sessionId), { syndicate: syndicate.id, ...(pause ? { pause } : {}) });
249
+ return { status, sessionId, text: r.text, ...(pause ? { pause } : {}) };
250
+ };
251
+ const toResult = (syndicate, o) => {
252
+ const structured = { session_id: o.sessionId, status: o.status };
253
+ if (o.pause) {
254
+ const { kind, ...rest } = o.pause;
255
+ structured.waiting_for = { kind, ...rest };
256
+ const text = describePause(o.pause, o.sessionId);
257
+ return { content: [{ type: 'text', text }, { type: 'text', text: sessionLine(o.sessionId) }], structuredContent: structured };
258
+ }
259
+ if (o.status !== 'completed') {
260
+ const text = o.text || (o.status === 'canceled' ? 'The task was canceled.' : 'The turn did not complete.');
261
+ return { content: [{ type: 'text', text }, { type: 'text', text: sessionLine(o.sessionId) }], structuredContent: structured, isError: true };
262
+ }
263
+ if (declaresStructuredOutput(syndicate.config)) {
264
+ try {
265
+ const parsed = JSON.parse(o.text);
266
+ if (parsed && typeof parsed === 'object')
267
+ structured.output = parsed;
268
+ }
269
+ catch {
270
+ /* the text stands as the answer */
271
+ }
272
+ }
273
+ return { content: [{ type: 'text', text: o.text }, { type: 'text', text: sessionLine(o.sessionId) }], structuredContent: structured };
274
+ };
275
+ const syndicateTool = (s) => {
276
+ const declared = (s.config.orchestrator?.description ?? '').trim();
277
+ const about = declared || `Ask the ${s.config.syndicate_name} syndicate.`;
278
+ const description = `${about.length > MAX_DESCRIPTION_CHARS ? `${about.slice(0, MAX_DESCRIPTION_CHARS)}…` : about}\n\n`
279
+ + 'Runs one turn of this melch syndicate and returns its answer. Omit session_id to start a new conversation; '
280
+ + 'the result carries the session_id to pass back to continue it. A turn may pause for a person '
281
+ + `(an approval, a question, an authorization): answer it with ${RESUME_TOOL}.`;
282
+ return {
283
+ name: s.id,
284
+ title: s.config.syndicate_name,
285
+ description,
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ message: { type: 'string', description: 'What to ask the syndicate.' },
290
+ session_id: { type: 'string', description: 'A session_id from an earlier result, to continue that conversation.' },
291
+ },
292
+ required: ['message'],
293
+ additionalProperties: false,
294
+ },
295
+ };
296
+ };
297
+ const resumeTool = {
298
+ name: RESUME_TOOL,
299
+ title: 'Resume a paused melch turn',
300
+ description: 'Answers a melch turn that paused for a person, on the session_id its result named. For an approval pass approve '
301
+ + '(true runs the call, false refuses it) and only after the person decided: never approve on your own. For a question pass answer '
302
+ + '(the person\'s words). For an authorization, call it once the person has opened the link and granted access.',
303
+ inputSchema: {
304
+ type: 'object',
305
+ properties: {
306
+ session_id: { type: 'string', description: 'The session_id of the paused turn.' },
307
+ approve: { type: 'boolean', description: 'The person\'s decision on a waiting approval.' },
308
+ answer: { type: 'string', description: 'The person\'s answer to a waiting question.' },
309
+ },
310
+ required: ['session_id'],
311
+ additionalProperties: false,
312
+ },
313
+ };
314
+ const tools = () => [...[...byName.values()].map(syndicateTool), resumeTool];
315
+ const readSessionId = (raw) => {
316
+ if (raw === undefined || raw === null || raw === '')
317
+ return randomUUID();
318
+ if (typeof raw !== 'string' || !SESSION_ID_PATTERN.test(raw)) {
319
+ return { error: 'session_id must be a session_id an earlier result returned ([A-Za-z0-9_.:-], at most 128 characters).' };
320
+ }
321
+ return raw;
322
+ };
323
+ const call = async (caller, toolName, rawArgs, extra = {}) => {
324
+ const args = (rawArgs && typeof rawArgs === 'object' ? rawArgs : {});
325
+ try {
326
+ if (toolName === RESUME_TOOL) {
327
+ const sid = args.session_id;
328
+ if (typeof sid !== 'string' || !SESSION_ID_PATTERN.test(sid))
329
+ return textResult('session_id is required: the session_id of the paused turn.', true);
330
+ const known = sessions.get(sessionKey(caller, sid));
331
+ if (!known) {
332
+ return textResult(`No paused turn is known on session ${sid}. Call the syndicate's own tool with this session_id and a message to continue it (a waiting approval is repeated, not answered).`, true);
333
+ }
334
+ const syndicate = byName.get(known.syndicate);
335
+ if (!syndicate)
336
+ return textResult(`Session ${sid} belongs to a syndicate this server no longer serves.`, true);
337
+ const pause = known.pause;
338
+ if (!pause)
339
+ return textResult(`Nothing is waiting on session ${sid}; call ${syndicate.id} with this session_id to continue the conversation.`, true);
340
+ let parts;
341
+ if (pause.kind === 'approval') {
342
+ if (typeof args.approve !== 'boolean')
343
+ return textResult(`${describePause(pause, sid)}\n(approve is required: true or false.)`, true);
344
+ parts = [{ kind: 'data', data: { approval: { id: pause.id, approved: args.approve } } }];
345
+ log(`${RESUME_TOOL}: approval ${pause.id.slice(0, 12)} answered on session ${sid.slice(0, 8)}`);
346
+ }
347
+ else if (pause.kind === 'input') {
348
+ if (typeof args.answer !== 'string' || !args.answer.trim())
349
+ return textResult(`${describePause(pause, sid)}\n(answer is required.)`, true);
350
+ if (args.answer.length > MAX_MESSAGE_CHARS)
351
+ return textResult(`answer is longer than ${MAX_MESSAGE_CHARS} characters.`, true);
352
+ parts = [{ kind: 'text', text: args.answer }];
353
+ }
354
+ else {
355
+ parts = [{ kind: 'text', text: typeof args.answer === 'string' && args.answer.trim() ? args.answer.slice(0, MAX_MESSAGE_CHARS) : 'continue' }];
356
+ }
357
+ return toResult(syndicate, await runTask(caller, syndicate, sid, parts, extra));
358
+ }
359
+ const syndicate = byName.get(toolName);
360
+ if (!syndicate)
361
+ return textResult(`Unknown tool: ${toolName}`, true);
362
+ const message = args.message;
363
+ if (typeof message !== 'string' || !message.trim())
364
+ return textResult('message is required: what to ask the syndicate.', true);
365
+ if (message.length > MAX_MESSAGE_CHARS)
366
+ return textResult(`message is longer than ${MAX_MESSAGE_CHARS} characters.`, true);
367
+ const sid = readSessionId(args.session_id);
368
+ if (typeof sid !== 'string')
369
+ return textResult(sid.error, true);
370
+ const known = sessions.get(sessionKey(caller, sid));
371
+ if (known && known.syndicate !== syndicate.id) {
372
+ return textResult(`Session ${sid} is a conversation with ${known.syndicate}; call that tool, or omit session_id for a new one.`, true);
373
+ }
374
+ // A waiting approval is answered only through melch_resume's explicit
375
+ // approve, never by a message that happens to read "approve".
376
+ if (known?.pause?.kind === 'approval') {
377
+ return toResult(syndicate, { status: 'input-required', sessionId: sid, text: '', pause: known.pause });
378
+ }
379
+ return toResult(syndicate, await runTask(caller, syndicate, sid, [{ kind: 'text', text: message }], extra));
380
+ }
381
+ catch (err) {
382
+ warn(`${toolName} failed: ${err instanceof Error ? err.message.split('\n')[0] : 'unknown error'}`);
383
+ return textResult('Internal Error: the request could not be completed. See server logs for details.', true);
384
+ }
385
+ };
386
+ const connect = (caller) => {
387
+ const server = new Server({ name, version }, { capabilities: { tools: {} } });
388
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools() }));
389
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
390
+ const progressToken = req.params._meta?.progressToken;
391
+ let progress = 0;
392
+ const onProgress = progressToken === undefined
393
+ ? undefined
394
+ : (message) => {
395
+ progress += 1;
396
+ void extra.sendNotification({ method: 'notifications/progress', params: { progressToken, progress, message } }).catch(() => { });
397
+ };
398
+ return call(caller, req.params.name, req.params.arguments ?? {}, { signal: extra.signal, ...(onProgress ? { onProgress } : {}) });
399
+ });
400
+ return server;
401
+ };
402
+ return {
403
+ tools,
404
+ connect,
405
+ call,
406
+ shutdown: (graceMs) => limiter.drain(graceMs),
407
+ get inFlight() {
408
+ return limiter.inFlight;
409
+ },
410
+ };
411
+ }
412
+ // ── Transports ───────────────────────────────────────────────────────────────
413
+ /**
414
+ * Serve one local client over stdio. stdout carries the protocol and nothing
415
+ * else: the bin sends every console line to stderr before anything loads.
416
+ */
417
+ export async function serveMcpStdio(mcp, opts = {}) {
418
+ const server = mcp.connect(opts.caller ?? LOCAL_CALLER);
419
+ await server.connect(new StdioServerTransport(opts.stdin, opts.stdout));
420
+ return server;
421
+ }
422
+ const isLoopbackHost = (host) => host === '127.0.0.1' || host === '::1' || host === 'localhost';
423
+ /**
424
+ * Why a Streamable HTTP bind must not start, or undefined. The A2A server's
425
+ * rule (ADR 0039), without its opt-out: beyond loopback a secret is required.
426
+ */
427
+ export function mcpBindProblem(opts) {
428
+ if (isLoopbackHost(opts.host))
429
+ return undefined;
430
+ if (!opts.secret) {
431
+ return `MCP_HOST=${opts.host} would expose the syndicates' tools unauthenticated. Set MCP_SERVER_SECRET (openssl rand -hex 32), or bind 127.0.0.1.`;
432
+ }
433
+ const min = opts.minSecretLength ?? 32;
434
+ if (opts.secret.length < min)
435
+ return `MCP_SERVER_SECRET must be at least ${min} characters on a bind beyond loopback (openssl rand -hex 32).`;
436
+ return undefined;
437
+ }
438
+ /**
439
+ * Streamable HTTP at POST/GET/DELETE /mcp, stateful (Mcp-Session-Id), so a
440
+ * cancellation reaches the request it names. Order: the Host check (DNS
441
+ * rebinding on loopback), the request rate limit, the failed-auth limiter,
442
+ * the constant-time bearer check, the body parser, then the transport.
443
+ */
444
+ export function mcpHttpApp(mcp, opts = {}) {
445
+ const host = opts.host ?? '127.0.0.1';
446
+ const problem = mcpBindProblem({ host, ...(opts.secret ? { secret: opts.secret } : {}) });
447
+ if (problem)
448
+ throw new Error(problem);
449
+ const warn = opts.warn ?? ((m) => console.error(`[MCP] ⚠ ${m.replace(/[\r\n]/g, ' ')}`));
450
+ const app = express();
451
+ app.disable('x-powered-by');
452
+ app.set('trust proxy', opts.trustProxy ?? false);
453
+ if (opts.allowedHosts?.length)
454
+ app.use(hostHeaderValidation(opts.allowedHosts));
455
+ else if (isLoopbackHost(host))
456
+ app.use(hostHeaderValidation(['127.0.0.1', 'localhost', '[::1]']));
457
+ app.get('/healthz', (_req, res) => {
458
+ res.json({ status: 'ok' });
459
+ });
460
+ app.use(rateLimit({ windowMs: 60_000, limit: opts.rateLimitPerMinute ?? 240, standardHeaders: true, legacyHeaders: false }));
461
+ opts.beforeAuth?.(app);
462
+ app.use(rateLimit({
463
+ windowMs: 15 * 60 * 1000,
464
+ limit: opts.authFailureMax ?? 30,
465
+ standardHeaders: true,
466
+ legacyHeaders: false,
467
+ skipSuccessfulRequests: true,
468
+ requestWasSuccessful: (_req, res) => res.statusCode !== 401,
469
+ message: { error: 'Too many failed authentication attempts; try again later.' },
470
+ }));
471
+ if (opts.secret) {
472
+ const expected = Buffer.from(opts.secret);
473
+ app.use((req, res, next) => {
474
+ const header = req.headers.authorization;
475
+ const token = header?.startsWith('Bearer ') ? Buffer.from(header.substring(7)) : undefined;
476
+ if (!token || token.length !== expected.length || !timingSafeEqual(token, expected)) {
477
+ warn(`401 ${req.method} ${req.path.replace(/[\r\n]/g, '')} from ${String(req.ip).replace(/[\r\n]/g, '')}`);
478
+ res.status(401).json({ error: 'Unauthorized: missing or invalid Authorization Bearer token' });
479
+ return;
480
+ }
481
+ next();
482
+ });
483
+ }
484
+ app.use(express.json({ limit: opts.bodyLimit ?? '1mb' }));
485
+ const maxSessions = opts.maxSessions ?? 256;
486
+ const idleMs = opts.sessionIdleMs ?? 30 * 60 * 1000;
487
+ const open = new Map();
488
+ const sweep = setInterval(() => {
489
+ const now = Date.now();
490
+ for (const [id, s] of open)
491
+ if (now - s.seen > idleMs)
492
+ void s.transport.close().catch(() => { });
493
+ }, Math.min(idleMs, 60_000));
494
+ sweep.unref();
495
+ const callerOf = (req) => {
496
+ const raw = req.headers['x-user-id'];
497
+ if (raw !== undefined && raw !== '') {
498
+ if (typeof raw !== 'string' || !HEADER_VALUE_PATTERN.test(raw))
499
+ return { error: 'Invalid X-User-Id: must match [A-Za-z0-9._-]{1,64}' };
500
+ }
501
+ const scopeKey = typeof raw === 'string' && raw ? raw : 'default';
502
+ return { scopeKey, caller: opts.secret ? 'shared-secret' : 'local', ...(req.ip ? { sourceIp: req.ip } : {}) };
503
+ };
504
+ const handle = async (req, res) => {
505
+ const id = req.headers['mcp-session-id'];
506
+ const caller = callerOf(req);
507
+ if ('error' in caller) {
508
+ res.status(400).json({ error: caller.error });
509
+ return;
510
+ }
511
+ if (typeof id === 'string' && id) {
512
+ const s = open.get(id);
513
+ if (!s) {
514
+ res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null });
515
+ return;
516
+ }
517
+ // A session is bound to the scope that opened it.
518
+ if (s.scopeKey !== caller.scopeKey) {
519
+ res.status(403).json({ jsonrpc: '2.0', error: { code: -32003, message: 'This session belongs to another user' }, id: null });
520
+ return;
521
+ }
522
+ s.seen = Date.now();
523
+ await s.transport.handleRequest(req, res, req.body);
524
+ return;
525
+ }
526
+ if (req.method !== 'POST' || !isInitializeRequest(req.body)) {
527
+ res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: no valid session id' }, id: null });
528
+ return;
529
+ }
530
+ if (open.size >= maxSessions) {
531
+ res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Too many open sessions; retry shortly' }, id: null });
532
+ return;
533
+ }
534
+ const server = mcp.connect(caller);
535
+ const transport = new StreamableHTTPServerTransport({
536
+ sessionIdGenerator: () => randomUUID(),
537
+ onsessioninitialized: (sid) => {
538
+ open.set(sid, { transport, server, seen: Date.now(), scopeKey: caller.scopeKey });
539
+ },
540
+ });
541
+ transport.onclose = () => {
542
+ if (transport.sessionId)
543
+ open.delete(transport.sessionId);
544
+ void server.close().catch(() => { });
545
+ };
546
+ await server.connect(transport);
547
+ await transport.handleRequest(req, res, req.body);
548
+ };
549
+ const safe = (req, res) => handle(req, res).catch((err) => {
550
+ warn(`MCP request failed: ${err instanceof Error ? err.message.split('\n')[0] : 'unknown error'}`);
551
+ if (!res.headersSent)
552
+ res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null });
553
+ });
554
+ app.post('/mcp', safe);
555
+ app.get('/mcp', safe);
556
+ app.delete('/mcp', safe);
557
+ return Object.assign(app, {
558
+ closeSessions: async () => {
559
+ clearInterval(sweep);
560
+ await Promise.all([...open.values()].map((s) => s.transport.close().catch(() => { })));
561
+ open.clear();
562
+ },
563
+ });
564
+ }
@@ -444,7 +444,7 @@ export class SupabaseVectorMemoryService {
444
444
  console.error(`[MemoryService] Dedup probe failed (storing anyway):`, err instanceof Error ? err.message : err);
445
445
  }
446
446
  if (near && isSemanticDuplicate(record, near)) {
447
- console.log(`[MemoryService] Duplicate skipped [${record.tag}]: "${record.line.slice(0, 70)}..."`);
447
+ console.log(`[MemoryService] Duplicate skipped [${record.tag}] (${record.line.length} chars)`);
448
448
  continue;
449
449
  }
450
450
  payload.push({
@@ -519,7 +519,7 @@ export class SupabaseVectorMemoryService {
519
519
  continue;
520
520
  taken.add(row.id);
521
521
  out.push({ id: row.id, byFact: correction.line });
522
- console.log(`[MemoryService] Supersedes: "${row.fact.slice(0, 60)}..."`);
522
+ console.log(`[MemoryService] Supersedes ${row.id}`);
523
523
  }
524
524
  }
525
525
  return out;
@@ -14,6 +14,8 @@
14
14
  * changes nothing; `adk`, the ADK Gemini adapter that 1.0.0 removed, throws
15
15
  * an error naming the release.
16
16
  */
17
+ import { ChatGptSignInAdapter } from '../chatgpt/adapter.js';
18
+ import { chatGptSignInRoutesOpenAi } from '../chatgpt/state.js';
17
19
  import { ClaudeAdapter } from './claudeAdapter.js';
18
20
  import { mergeEndpoint } from './endpoints.js';
19
21
  import { FallbackAdapter } from './fallbackAdapter.js';
@@ -76,7 +78,10 @@ export function geminiAdapterSetting(env = process.env) {
76
78
  const CONTRACT_ADAPTER = {
77
79
  ollama: (r) => new OllamaAdapter({ model: r.model }),
78
80
  anthropic: (r) => new ClaudeAdapter({ model: r.model, apiKey: r.apiKey, endpoint: r.endpoint }),
79
- openai: (r) => new GptAdapter({ model: r.model, apiKey: r.apiKey, endpoint: r.endpoint }),
81
+ // No key and no caller endpoint: a stored Sign in with ChatGPT carries the id, on this machine only (ADR 0126).
82
+ openai: (r) => !r.apiKey && !r.endpoint && chatGptSignInRoutesOpenAi()
83
+ ? new ChatGptSignInAdapter({ model: r.model })
84
+ : new GptAdapter({ model: r.model, apiKey: r.apiKey, endpoint: r.endpoint }),
80
85
  xai: (r) => new GrokAdapter({ model: r.model, apiKey: r.apiKey }),
81
86
  moonshot: (r) => new KimiAdapter({ model: r.model, apiKey: r.apiKey, baseUrl: r.endpoint?.baseURL }),
82
87
  };
@@ -24,7 +24,7 @@
24
24
  * native search. lib/models/capabilities.ts turns that into a per-agent
25
25
  * report the doctor, the A2A startup log and the ledger all read.
26
26
  *
27
- * WHY a leaf (imports providerMap and endpoints only):
27
+ * WHY a leaf (imports providerMap, endpoints and lib/chatgpt/state.ts only):
28
28
  * The registry, the gateway adapter, the capability report and the
29
29
  * doctor all need this decision; keeping it free of adapter imports
30
30
  * avoids the cycle model-routing warns about.
@@ -24,7 +24,7 @@
24
24
  * native search. lib/models/capabilities.ts turns that into a per-agent
25
25
  * report the doctor, the A2A startup log and the ledger all read.
26
26
  *
27
- * WHY a leaf (imports providerMap and endpoints only):
27
+ * WHY a leaf (imports providerMap, endpoints and lib/chatgpt/state.ts only):
28
28
  * The registry, the gateway adapter, the capability report and the
29
29
  * doctor all need this decision; keeping it free of adapter imports
30
30
  * avoids the cycle model-routing warns about.
@@ -37,6 +37,7 @@
37
37
  * MODEL_GATEWAY_MODEL_MAP optional "from=to,from=to" wire-name overrides
38
38
  * for ids the default mapper gets wrong
39
39
  */
40
+ import { chatGptSignInRoutesOpenAi } from '../chatgpt/state.js';
40
41
  import { providerReady } from './endpoints.js';
41
42
  import { PROVIDERS, providerForModel } from './providerMap.js';
42
43
  import { trimTrailingSlashes } from './urls.js';
@@ -148,7 +149,8 @@ export function gatewayWireModel(model, gateway) {
148
149
  export function planTransport(model, opts = {}) {
149
150
  const provider = providerForModel(model);
150
151
  const keyEnv = PROVIDERS[provider].keyEnv;
151
- const direct = providerReady(provider) || !!opts.callerKey;
152
+ // A Sign in with ChatGPT credential funds OpenAI directly when no key does (ADR 0126).
153
+ const direct = providerReady(provider) || !!opts.callerKey || (provider === 'openai' && chatGptSignInRoutesOpenAi());
152
154
  if (direct || provider === 'ollama') {
153
155
  return { provider, transport: 'direct', funded: true, keyEnv };
154
156
  }