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,145 @@
1
+ /**
2
+ * lib/chatgpt/state.ts — where a Sign in with ChatGPT credential lives, whether
3
+ * it is the OpenAI path, and the served-surface refusal (ADR 0126).
4
+ *
5
+ * WHY a leaf (node:fs, node:os, node:path only):
6
+ * The gateway's transport rule (lib/models/gateway.ts), the doctor, the
7
+ * resolver and every served bin ask the same two questions — is a sign-in
8
+ * stored, and does it carry OpenAI ids here — so the answer lives in one
9
+ * module with no adapter or SDK in its import graph.
10
+ *
11
+ * LOCAL ONLY:
12
+ * OpenAI's plan-usage flow is for open-source apps that run on the user's
13
+ * own machine (developers.openai.com/siwc/token-sharing-open-source). The
14
+ * A2A server, the worker and any other served surface call
15
+ * `refuseChatGptSignInOnServedSurface` at startup and stop while the
16
+ * sign-in would carry OpenAI ids; `markServedSurface` also makes the
17
+ * adapter refuse at call time, so a surface that skipped the startup check
18
+ * still never spends a person's ChatGPT plan on someone else's request.
19
+ *
20
+ * WHAT IT NEVER DOES: return or print a token. The status carries presence,
21
+ * times and the file's path.
22
+ */
23
+ import { existsSync, readFileSync, statSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ /** Where the credential file lives, when not the default (a path; never a value). */
27
+ export const CHATGPT_SIGNIN_FILE_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN_FILE';
28
+ /** `off` ignores a stored sign-in (a served process on a machine that also signed in). */
29
+ export const CHATGPT_SIGNIN_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN';
30
+ /** The scope that lets the token use the person's ChatGPT plan. */
31
+ export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
32
+ /** The credential file: MELCHIZEDEK_CHATGPT_SIGNIN_FILE, else ~/.melchizedek/chatgpt-signin.json. */
33
+ export function signInFile(env = process.env) {
34
+ const set = env[CHATGPT_SIGNIN_FILE_ENV]?.trim();
35
+ return set ? resolve(set) : join(homedir(), '.melchizedek', 'chatgpt-signin.json');
36
+ }
37
+ /** The git work tree a path sits inside, if any (an ancestor holding `.git`). */
38
+ export function insideGitWorkTree(file) {
39
+ let dir = dirname(resolve(file));
40
+ for (;;) {
41
+ if (existsSync(join(dir, '.git')))
42
+ return dir;
43
+ const parent = dirname(dir);
44
+ if (parent === dir)
45
+ return undefined;
46
+ dir = parent;
47
+ }
48
+ }
49
+ /** Whether a parsed value has the stored shape. */
50
+ export function isStoredSignIn(v) {
51
+ if (!v || typeof v !== 'object')
52
+ return false;
53
+ const o = v;
54
+ return o.version === 1 && typeof o.issuer === 'string' && typeof o.hostId === 'string';
55
+ }
56
+ /**
57
+ * Read the credential file: undefined when absent; throws, naming the file
58
+ * and never its content, when it is readable by others or malformed.
59
+ */
60
+ export function readStoredSignIn(file) {
61
+ if (!existsSync(file))
62
+ return undefined;
63
+ if (process.platform !== 'win32' && (statSync(file).mode & 0o077) !== 0) {
64
+ throw new Error(`${file} is readable by other users; run \`chmod 600 ${file}\` (or sign in again).`);
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(readFileSync(file, 'utf-8'));
69
+ }
70
+ catch {
71
+ throw new Error(`${file} is not a Sign in with ChatGPT credential file; delete it and sign in again.`);
72
+ }
73
+ if (!isStoredSignIn(parsed))
74
+ throw new Error(`${file} is not a Sign in with ChatGPT credential file; delete it and sign in again.`);
75
+ return parsed;
76
+ }
77
+ /** Signed in: a refresh token, and the plan scope granted. */
78
+ export function isSignedIn(s) {
79
+ return !!s?.refreshToken && !!s.accessToken && (s.scopes ?? []).includes(PLAN_SCOPE);
80
+ }
81
+ /** The stored sign-in's status under this environment. */
82
+ export function chatGptSignInStatus(env = process.env) {
83
+ const file = signInFile(env);
84
+ const enabled = env[CHATGPT_SIGNIN_ENV]?.trim().toLowerCase() !== 'off';
85
+ const present = existsSync(file);
86
+ if (!present)
87
+ return { enabled, file, present, signedIn: false };
88
+ try {
89
+ const s = readStoredSignIn(file);
90
+ const signedIn = isSignedIn(s);
91
+ return { enabled, file, present, signedIn, ...(signedIn && s?.expiresAt ? { expiresAt: s.expiresAt } : {}) };
92
+ }
93
+ catch (err) {
94
+ return { enabled, file, present, signedIn: false, problem: err instanceof Error ? err.message : String(err) };
95
+ }
96
+ }
97
+ /**
98
+ * Whether OpenAI ids would run on the sign-in here: it is enabled and signed
99
+ * in, no OPENAI_API_KEY is set, and OpenAI is on its own API (no
100
+ * OPENAI_PLATFORM other than direct, no OPENAI_BASE_URL proxy, which must
101
+ * never receive the token).
102
+ */
103
+ export function chatGptSignInRoutesOpenAi(env = process.env) {
104
+ if (env.OPENAI_API_KEY?.trim())
105
+ return false;
106
+ const platform = env.OPENAI_PLATFORM?.trim().toLowerCase();
107
+ if (platform && platform !== 'direct')
108
+ return false;
109
+ if (env.OPENAI_BASE_URL?.trim())
110
+ return false;
111
+ const status = chatGptSignInStatus(env);
112
+ return status.enabled && status.signedIn;
113
+ }
114
+ // ── Local only ───────────────────────────────────────────────────────────────
115
+ let served;
116
+ /** Mark this process a served surface: the sign-in adapter refuses every call from here on. */
117
+ export function markServedSurface(surface) {
118
+ served ??= surface;
119
+ }
120
+ /** The served surface this process was marked as, if any. */
121
+ export function servedSurface() {
122
+ return served;
123
+ }
124
+ /** Test seam: forget the mark. */
125
+ export function resetServedSurfaceForTests() {
126
+ served = undefined;
127
+ }
128
+ /** The message a served surface stops with. */
129
+ export function servedSurfaceMessage(surface, env = process.env) {
130
+ return (`${surface} will not start: Sign in with ChatGPT is local only, and it is the OpenAI path here ` +
131
+ `(a sign-in is stored at ${signInFile(env)} and OPENAI_API_KEY is not set). ` +
132
+ `A served surface answers other people, and a ChatGPT plan pays only for its owner's own use on their own machine. ` +
133
+ `Set OPENAI_API_KEY (or Azure OpenAI), or set ${CHATGPT_SIGNIN_ENV}=off for this process, ` +
134
+ `or sign out with \`melchizedek-setup --chatgpt-signout\`.`);
135
+ }
136
+ /**
137
+ * Called by every served surface at startup: marks the process served, and
138
+ * throws when the sign-in would carry OpenAI ids here. The A2A app, the A2A
139
+ * server bin and the worker call it; a new served surface calls it too.
140
+ */
141
+ export function refuseChatGptSignInOnServedSurface(surface, env = process.env) {
142
+ markServedSurface(surface);
143
+ if (chatGptSignInRoutesOpenAi(env))
144
+ throw new Error(servedSurfaceMessage(surface, env));
145
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * lib/chatgpt/store.ts — writing the Sign in with ChatGPT credential file and
3
+ * serializing its refreshes (ADR 0126).
4
+ *
5
+ * The file is the person's own: mode 600 in a mode-700 directory, outside any
6
+ * git work tree, replaced atomically (a new mode-600 file renamed over the
7
+ * old one). OpenAI's refresh tokens rotate, so two processes must not refresh
8
+ * the same session at once (developers.openai.com/siwc/token-sharing-open-source/
9
+ * profiles-and-sessions): a lock file beside the credential serializes them.
10
+ */
11
+ import type { StoredSignIn } from './state.ts';
12
+ /** Write the credential file: mode 600, atomically, never inside a git work tree. */
13
+ export declare function writeStoredSignIn(file: string, value: StoredSignIn): void;
14
+ /** The stored sign-in with its tokens removed; the host id and the issued client id stay (OpenAI asks apps to reuse them). */
15
+ export declare function withoutTokens(s: StoredSignIn): StoredSignIn;
16
+ /** Remove the tokens from the file, keeping the registration. */
17
+ export declare function clearTokens(file: string): void;
18
+ /**
19
+ * Run `fn` holding the credential's lock file (created exclusively). A lock
20
+ * older than a minute is a crashed holder's and is taken over.
21
+ */
22
+ export declare function withSignInLock<T>(file: string, fn: () => Promise<T>, opts?: {
23
+ waitMs?: number;
24
+ }): Promise<T>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * lib/chatgpt/store.ts — writing the Sign in with ChatGPT credential file and
3
+ * serializing its refreshes (ADR 0126).
4
+ *
5
+ * The file is the person's own: mode 600 in a mode-700 directory, outside any
6
+ * git work tree, replaced atomically (a new mode-600 file renamed over the
7
+ * old one). OpenAI's refresh tokens rotate, so two processes must not refresh
8
+ * the same session at once (developers.openai.com/siwc/token-sharing-open-source/
9
+ * profiles-and-sessions): a lock file beside the credential serializes them.
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, renameSync, rmSync, statSync, writeSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { setTimeout as delay } from 'node:timers/promises';
15
+ import { insideGitWorkTree, readStoredSignIn } from './state.js';
16
+ /** Write the credential file: mode 600, atomically, never inside a git work tree. */
17
+ export function writeStoredSignIn(file, value) {
18
+ const repo = insideGitWorkTree(file);
19
+ if (repo) {
20
+ throw new Error(`refusing to store a ChatGPT sign-in inside the git work tree ${repo}; set MELCHIZEDEK_CHATGPT_SIGNIN_FILE to a path outside it.`);
21
+ }
22
+ const dir = dirname(file);
23
+ if (!existsSync(dir))
24
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+ const tmp = `${file}.${randomUUID()}.tmp`;
26
+ const fd = openSync(tmp, 'wx', 0o600);
27
+ try {
28
+ writeSync(fd, `${JSON.stringify(value, null, 2)}\n`);
29
+ }
30
+ finally {
31
+ closeSync(fd);
32
+ }
33
+ try {
34
+ if (process.platform !== 'win32')
35
+ chmodSync(tmp, 0o600);
36
+ renameSync(tmp, file);
37
+ }
38
+ catch (err) {
39
+ rmSync(tmp, { force: true });
40
+ throw err;
41
+ }
42
+ }
43
+ /** The stored sign-in with its tokens removed; the host id and the issued client id stay (OpenAI asks apps to reuse them). */
44
+ export function withoutTokens(s) {
45
+ const { accessToken: _a, refreshToken: _r, expiresAt: _e, earliestRefreshAt: _n, scopes: _s, ...kept } = s;
46
+ return { ...kept, savedAt: new Date().toISOString() };
47
+ }
48
+ /** Remove the tokens from the file, keeping the registration. */
49
+ export function clearTokens(file) {
50
+ const s = readStoredSignIn(file);
51
+ if (s)
52
+ writeStoredSignIn(file, withoutTokens(s));
53
+ }
54
+ const LOCK_STALE_MS = 60_000;
55
+ const LOCK_WAIT_MS = 20_000;
56
+ /**
57
+ * Run `fn` holding the credential's lock file (created exclusively). A lock
58
+ * older than a minute is a crashed holder's and is taken over.
59
+ */
60
+ export async function withSignInLock(file, fn, opts = {}) {
61
+ const lock = `${file}.lock`;
62
+ const dir = dirname(file);
63
+ if (!existsSync(dir))
64
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
65
+ const deadline = Date.now() + (opts.waitMs ?? LOCK_WAIT_MS);
66
+ for (;;) {
67
+ try {
68
+ closeSync(openSync(lock, 'wx', 0o600));
69
+ break;
70
+ }
71
+ catch (err) {
72
+ if (err.code !== 'EEXIST')
73
+ throw err;
74
+ try {
75
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
76
+ rmSync(lock, { force: true });
77
+ continue;
78
+ }
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ if (Date.now() > deadline)
84
+ throw new Error(`another process holds ${lock}; try again, or delete it if no melchizedek process is running.`);
85
+ await delay(100);
86
+ }
87
+ }
88
+ try {
89
+ return await fn();
90
+ }
91
+ finally {
92
+ rmSync(lock, { force: true });
93
+ }
94
+ }
@@ -145,10 +145,10 @@ export function examplesTool(examples) {
145
145
  const tool = examplesInstructionTool(examples);
146
146
  return tool ? [tool] : [];
147
147
  }
148
- async function resolveAgentTools(toolNames, mcpServerUrl, opts, openapi, examples, mcpAllowed, mcpAuth) {
149
- const tools = [...resolveNamedTools(toolNames, opts.onUnknownTool), ...examplesTool(examples)];
148
+ async function resolveAgentTools(agent, opts) {
149
+ const tools = [...resolveNamedTools(agent.tools, opts.onUnknownTool), ...examplesTool(agent.examples)];
150
150
  // OpenAPI operations become tools here, so require_approval can name them.
151
- for (const entry of openapi ?? []) {
151
+ for (const entry of agent.openapi ?? []) {
152
152
  const built = await buildOpenApiTools(entry);
153
153
  opts.log?.(`openapi · ${relative(process.cwd(), entry.spec) || entry.spec}: ${built.map((t) => t.name).join(', ') || '(no operations)'}`);
154
154
  for (const t of built) {
@@ -157,28 +157,43 @@ async function resolveAgentTools(toolNames, mcpServerUrl, opts, openapi, example
157
157
  tools.push(t);
158
158
  }
159
159
  }
160
- if (mcpServerUrl) {
161
- opts.log?.(`Loading MCP tools: ${mcpServerUrl}`);
162
- // An authorization_code server lists nothing at startup: its tools are the mcp_tools names, each on its user's own connection (ADR 0112).
163
- const offered = await createMcpTools(mcpServerUrl, { ...(mcpAllowed ? { tools: mcpAllowed } : {}), ...(mcpAuth?.oauth2 ? { oauth2: mcpAuth.oauth2 } : {}) });
164
- // mcp_tools: only the named tools are exposed (a server's list is its
165
- // own to change); a name the server does not offer is reported, and a
166
- // gate on it fails the compile in gateTools.
167
- const allowed = mcpAllowed ? new Set(mcpAllowed) : undefined;
168
- const mcpTools = allowed ? offered.filter((t) => allowed.has(t.name)) : offered;
169
- if (allowed) {
170
- const missing = [...allowed].filter((n) => !offered.some((t) => t.name === n));
171
- if (missing.length)
172
- opts.log?.(`MCP ${mcpServerUrl} does not offer ${missing.map((n) => `'${n}'`).join(', ')} (mcp_tools)`);
173
- const hidden = offered.length - mcpTools.length;
174
- if (hidden)
175
- opts.log?.(`MCP ${mcpServerUrl}: ${hidden} tool(s) not in mcp_tools are not exposed`);
176
- }
177
- for (const mcpTool of mcpTools) {
160
+ /** One server's tools, narrowed to the names the YAML allows (a server's list is its own to change). */
161
+ const offeredBy = async (label, url, allowedNames, oauth2, transport) => {
162
+ opts.log?.(`Loading MCP tools: ${label}`);
163
+ // An authorization_code server lists nothing at startup: its tools are the allowed names, each on its user's own connection (ADR 0112).
164
+ const offered = await createMcpTools(url, { ...(allowedNames ? { tools: allowedNames } : {}), ...(oauth2 ? { oauth2 } : {}), ...(transport ? { transport } : {}) });
165
+ if (!allowedNames)
166
+ return offered;
167
+ // A name the server does not offer is reported, and a gate on it fails the compile in gateTools.
168
+ const allowed = new Set(allowedNames);
169
+ const kept = offered.filter((t) => allowed.has(t.name));
170
+ const missing = [...allowed].filter((n) => !offered.some((t) => t.name === n));
171
+ if (missing.length)
172
+ opts.log?.(`MCP ${label} does not offer ${missing.map((n) => `'${n}'`).join(', ')}`);
173
+ const hidden = offered.length - kept.length;
174
+ if (hidden)
175
+ opts.log?.(`MCP ${label}: ${hidden} tool(s) not named for this agent are not exposed`);
176
+ return kept;
177
+ };
178
+ if (agent.mcp_server_url) {
179
+ // The single-server form: a name the agent already has keeps the agent's tool.
180
+ for (const mcpTool of await offeredBy(agent.mcp_server_url, agent.mcp_server_url, agent.mcp_tools, agent.mcp_auth?.oauth2, agent.mcp_transport)) {
178
181
  if (!tools.some((t) => t.name === mcpTool.name))
179
182
  tools.push(mcpTool);
180
183
  }
181
184
  }
185
+ // mcp_servers (ADR 0124): the loader refused a declared name twice; a
186
+ // server that answers with a name already taken is refused here too, so
187
+ // no server's tool can stand in for another's or the agent's own.
188
+ const servers = await Promise.all((agent.mcp_servers ?? []).map((server) => offeredBy(`${server.name} (${server.url})`, server.url, server.tools, server.auth?.oauth2, server.transport)));
189
+ for (const [i, offered] of servers.entries()) {
190
+ const server = agent.mcp_servers[i];
191
+ for (const mcpTool of offered) {
192
+ if (tools.some((t) => t?.name === mcpTool.name))
193
+ throw new Error(`mcp_servers ${server.name}: tool '${mcpTool.name}' collides with another of this agent's tools (ADR 0124)`);
194
+ tools.push(mcpTool);
195
+ }
196
+ }
182
197
  return tools;
183
198
  }
184
199
  /**
@@ -413,7 +428,7 @@ export async function compileSubagentSpec(subCfg, opts = {}) {
413
428
  }
414
429
  // YAML v2 (ADR 0115): the engine form, for a config built in code (a loaded one already is).
415
430
  const agent = toEngineAgent(subCfg);
416
- const gated = gateTools(await resolveAgentTools(agent.tools, agent.mcp_server_url, opts, agent.openapi, agent.examples, agent.mcp_tools, agent.mcp_auth), agent.require_approval, agent.name);
431
+ const gated = gateTools(await resolveAgentTools(agent, opts), agent.require_approval, agent.name);
417
432
  const model = resolveAgentModel(agent.model, opts);
418
433
  // model_overrides for the provider the agent runs on, before the skills index is appended.
419
434
  const { instruction, tools } = await withSkills(instructionFor(agent, model.modelId), gated, agent.skills, agent.name, opts);
@@ -452,9 +467,9 @@ export async function compileSpec(config, opts = {}, overrideName, overrideDescr
452
467
  const orchestrator = toEngineAgent(config.orchestrator); // YAML v2 (ADR 0115), for a config built in code
453
468
  const name = overrideName || orchestrator.name;
454
469
  // The orchestrator's own tools resolve as a subagent's do: registry
455
- // names, OpenAPI operations, and the MCP server's tools (narrowed by
456
- // mcp_tools, under its mcp_auth grant), so a one-agent syndicate can reach an MCP server.
457
- const own = gateTools(await resolveAgentTools(orchestrator.tools, orchestrator.mcp_server_url, opts, orchestrator.openapi, orchestrator.examples, orchestrator.mcp_tools, orchestrator.mcp_auth), orchestrator.require_approval, name);
470
+ // names, OpenAPI operations, and the MCP servers' tools (narrowed by
471
+ // mcp_tools or each mcp_servers entry's tools, under its grant), so a one-agent syndicate can reach an MCP server.
472
+ const own = gateTools(await resolveAgentTools(orchestrator, opts), orchestrator.require_approval, name);
458
473
  const model = resolveAgentModel(orchestrator.model, opts);
459
474
  // The skills toolset goes last, after the delegations and the agent's own tools; the
460
475
  // skills index goes after the instruction with its model_overrides entry applied.
@@ -73,6 +73,8 @@ function matchSubagent(candidate, subagents) {
73
73
  return undefined;
74
74
  return subagents.find(sub => norm(sub.name) === target);
75
75
  }
76
+ /** What a subagent name can look like: one token, linear to test. */
77
+ const ROUTE_SHAPED = /^[A-Za-z0-9_.-]{1,64}$/;
76
78
  /**
77
79
  * Turn the router's raw output into a route that definitely exists.
78
80
  *
@@ -108,7 +110,8 @@ export function resolveRoute(rawOutput, config) {
108
110
  parsed = JSON.parse(unfence(rawOutput));
109
111
  }
110
112
  catch {
111
- return defaultResolution(`router output was not JSON: ${rawOutput.trim().slice(0, 80)}`);
113
+ // The reason reaches the server log: the size, never the text (the router can echo the person).
114
+ return defaultResolution(`router output was not JSON (${rawOutput.trim().length} chars)`);
112
115
  }
113
116
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
114
117
  return defaultResolution('router output was not a JSON object');
@@ -120,7 +123,8 @@ export function resolveRoute(rawOutput, config) {
120
123
  }
121
124
  const matched = matchSubagent(rawRoute, subagents);
122
125
  if (!matched) {
123
- return defaultResolution(`router chose unknown route '${rawRoute}'`);
126
+ // A route-shaped value is named; anything else is model text and is only measured.
127
+ return defaultResolution(ROUTE_SHAPED.test(rawRoute) ? `router chose unknown route '${rawRoute}'` : `router chose an unknown route (${rawRoute.length} chars)`);
124
128
  }
125
129
  const rawReason = record[reasonKey];
126
130
  return {
@@ -167,6 +167,25 @@ export interface DoctorResult {
167
167
  providers: ProviderPath[];
168
168
  /** How the A2A server would authenticate and bill, when any of its variables is set. */
169
169
  serving?: DoctorServing;
170
+ /** A stored Sign in with ChatGPT credential (ADR 0126), when one is present or its variables are set. */
171
+ chatgpt?: DoctorChatGpt;
172
+ }
173
+ /**
174
+ * A Sign in with ChatGPT credential (ADR 0126): where it is, whether it is
175
+ * signed in and carries OpenAI ids here, and what stops it. Never a token.
176
+ */
177
+ export interface DoctorChatGpt {
178
+ file: string;
179
+ /** False when MELCHIZEDEK_CHATGPT_SIGNIN=off. */
180
+ enabled: boolean;
181
+ present: boolean;
182
+ signedIn: boolean;
183
+ /** OpenAI ids run on it here (no OPENAI_API_KEY, OpenAI on its own API). */
184
+ routesOpenAi: boolean;
185
+ /** Epoch milliseconds the stored access token expires (it refreshes on use). */
186
+ expiresAt?: number;
187
+ /** A served configuration on it, or a file that cannot be used. `--check` fails on any. */
188
+ problems: string[];
170
189
  }
171
190
  /**
172
191
  * How one provider would be reached under this environment (the doctor's
@@ -183,6 +202,8 @@ export interface ProviderPath {
183
202
  /** `local` for Ollama; a cloud platform (vertex, bedrock, azure) or `direct` otherwise. */
184
203
  platform: Platform | 'local';
185
204
  keyEnv: string | null;
205
+ /** `chatgpt-signin` when a stored Sign in with ChatGPT funds the provider instead of its key (ADR 0126). */
206
+ credential?: 'chatgpt-signin';
186
207
  }
187
208
  /**
188
209
  * The A2A server's identity and billing settings (scripts/a2a_server.ts),
@@ -263,11 +284,21 @@ export declare function runDoctor(options?: {
263
284
  agentsDir?: string;
264
285
  scripts?: Record<string, string>;
265
286
  }): DoctorResult;
287
+ /**
288
+ * The Sign in with ChatGPT line (ADR 0126), when a credential file is present
289
+ * or one of its variables is set. `served`: a serving variable is set, so
290
+ * this configuration is meant for melchizedek-serve, which refuses to start
291
+ * while the sign-in is the OpenAI path.
292
+ */
293
+ export declare function chatGptReport(served: boolean, env?: NodeJS.ProcessEnv): DoctorChatGpt | undefined;
266
294
  /**
267
295
  * Why `melchizedek-doctor --check` exits non-zero, one line each; empty when
268
- * it passes. A blocked syndicate, a runtime problem, the OAuth setup's and
269
- * the credential allowlist's problems, and each grant whose hosts the OAuth
270
- * host allowlist refuses (ADR 0114). Names and hosts only, never a value.
296
+ * it passes. A blocked syndicate, a runtime problem, a `MODEL_GATEWAY` that
297
+ * names no gateway (named with the accepted values only: the value may be a
298
+ * key pasted into the wrong variable), the OAuth setup's and the credential
299
+ * allowlist's problems, and each grant whose hosts the OAuth host allowlist
300
+ * refuses (ADR 0114), and the Sign in with ChatGPT line's problems (ADR 0126).
301
+ * Names and hosts only, never a value.
271
302
  */
272
303
  export declare function checkProblems(result: DoctorResult): string[];
273
304
  export declare function renderDoctor(result: DoctorResult, opts?: {
@@ -34,6 +34,7 @@ import { credentialSource, endpointFromEnv, endpointLabel, endpointProblems, PLA
34
34
  import { GATEWAY_ENV, GATEWAY_KEY_ENV, GATEWAYS, gatewayConfig, gatewayProblem, gatewayUsable, } from './models/gateway.js';
35
35
  import { PROVIDERS } from './models/providerMap.js';
36
36
  import { describeRuntime } from './runtime/runtimeFlag.js';
37
+ import { CHATGPT_SIGNIN_ENV, CHATGPT_SIGNIN_FILE_ENV, chatGptSignInRoutesOpenAi, chatGptSignInStatus } from './chatgpt/state.js';
37
38
  import { agentOAuthGrants, oauthEnvNames } from './tools/oauthTools.js';
38
39
  import { grantHostProblems, OAUTH_REDIRECT_URI_ENV, oauthEnvProblems } from './a2a/oauthSetup.js';
39
40
  import { CREDENTIAL_KEY_ENV } from './tools/credentialCipher.js';
@@ -41,6 +42,8 @@ import { OAUTH_HOSTS_ENV } from './tools/oauthHosts.js';
41
42
  import { CREDENTIAL_HOSTS_ENV, credentialHosts } from './tools/credentialHosts.js';
42
43
  import { syndicateCredentialUses } from './tools/credentialUses.js';
43
44
  import { credentialHostEnvProblems, unboundCredentialWarning } from './a2a/oauthSetup.js';
45
+ /** The `gateway.id` the doctor reports when `MODEL_GATEWAY` names no known gateway; the value itself is never kept. */
46
+ const UNRECOGNISED_GATEWAY = 'unrecognised';
44
47
  function resolvable(module) {
45
48
  try {
46
49
  createRequire(import.meta.url).resolve(module);
@@ -115,6 +118,7 @@ export function providerPaths() {
115
118
  ...(r.gateway ? { gateway: r.gateway } : {}),
116
119
  platform: p === 'ollama' ? 'local' : (r.platform ?? 'direct'),
117
120
  keyEnv: r.keyEnv,
121
+ ...(p === 'openai' && r.funded && r.transport === 'direct' && chatGptSignInRoutesOpenAi() ? { credential: 'chatgpt-signin' } : {}),
118
122
  };
119
123
  });
120
124
  }
@@ -484,7 +488,7 @@ export function runDoctor(options = {}) {
484
488
  const gateway = rawGateway
485
489
  ? {
486
490
  // An unrecognised value is never echoed: it may be a key pasted into the wrong variable.
487
- id: cfg?.gateway.id ?? 'unrecognised',
491
+ id: cfg?.gateway.id ?? UNRECOGNISED_GATEWAY,
488
492
  label: cfg?.gateway.label ?? 'an unrecognised gateway',
489
493
  usable: gatewayUsable(),
490
494
  ...(gatewayProblem() ? { problem: gatewayProblem() } : {}),
@@ -523,6 +527,7 @@ export function runDoctor(options = {}) {
523
527
  credentials = { set: credentialSet, envs, ...(warning ? { warning } : {}), problems };
524
528
  }
525
529
  const serving = servingReport();
530
+ const chatgpt = chatGptReport(!!serving);
526
531
  return {
527
532
  agentsDir,
528
533
  runtime: runtimeReport(),
@@ -535,6 +540,34 @@ export function runDoctor(options = {}) {
535
540
  ...(credentials ? { credentials } : {}),
536
541
  providers: providerPaths(),
537
542
  ...(serving ? { serving } : {}),
543
+ ...(chatgpt ? { chatgpt } : {}),
544
+ };
545
+ }
546
+ /**
547
+ * The Sign in with ChatGPT line (ADR 0126), when a credential file is present
548
+ * or one of its variables is set. `served`: a serving variable is set, so
549
+ * this configuration is meant for melchizedek-serve, which refuses to start
550
+ * while the sign-in is the OpenAI path.
551
+ */
552
+ export function chatGptReport(served, env = process.env) {
553
+ const status = chatGptSignInStatus(env);
554
+ if (!status.present && !env[CHATGPT_SIGNIN_FILE_ENV]?.trim() && !env[CHATGPT_SIGNIN_ENV]?.trim())
555
+ return undefined;
556
+ const routesOpenAi = chatGptSignInRoutesOpenAi(env);
557
+ const problems = [];
558
+ if (status.problem)
559
+ problems.push(status.problem);
560
+ if (served && routesOpenAi) {
561
+ problems.push(`Sign in with ChatGPT is local only, and it is the OpenAI path in this served configuration: melchizedek-serve and melchizedek-worker refuse to start. Set OPENAI_API_KEY (or Azure OpenAI), or ${CHATGPT_SIGNIN_ENV}=off for the served process.`);
562
+ }
563
+ return {
564
+ file: status.file,
565
+ enabled: status.enabled,
566
+ present: status.present,
567
+ signedIn: status.signedIn,
568
+ routesOpenAi,
569
+ ...(status.expiresAt ? { expiresAt: status.expiresAt } : {}),
570
+ problems,
538
571
  };
539
572
  }
540
573
  // ── Rendering ────────────────────────────────────────────────────────────────
@@ -558,9 +591,12 @@ const PROBE_ID = {
558
591
  };
559
592
  /**
560
593
  * Why `melchizedek-doctor --check` exits non-zero, one line each; empty when
561
- * it passes. A blocked syndicate, a runtime problem, the OAuth setup's and
562
- * the credential allowlist's problems, and each grant whose hosts the OAuth
563
- * host allowlist refuses (ADR 0114). Names and hosts only, never a value.
594
+ * it passes. A blocked syndicate, a runtime problem, a `MODEL_GATEWAY` that
595
+ * names no gateway (named with the accepted values only: the value may be a
596
+ * key pasted into the wrong variable), the OAuth setup's and the credential
597
+ * allowlist's problems, and each grant whose hosts the OAuth host allowlist
598
+ * refuses (ADR 0114), and the Sign in with ChatGPT line's problems (ADR 0126).
599
+ * Names and hosts only, never a value.
564
600
  */
565
601
  export function checkProblems(result) {
566
602
  const problems = [];
@@ -568,10 +604,15 @@ export function checkProblems(result) {
568
604
  problems.push(`${result.counts.blocked} syndicate(s) blocked`);
569
605
  if (result.runtime.problem)
570
606
  problems.push(result.runtime.problem);
607
+ if (result.gateway?.id === UNRECOGNISED_GATEWAY) {
608
+ problems.push(`gateway: ${GATEWAY_ENV} is set to an unrecognised value (not shown); it must be one of: ${Object.keys(GATEWAYS).join(', ')}`);
609
+ }
571
610
  for (const p of result.oauth?.problems ?? [])
572
611
  problems.push(`oauth: ${p}`);
573
612
  for (const p of result.credentials?.problems ?? [])
574
613
  problems.push(`credentials: ${p}`);
614
+ for (const p of result.chatgpt?.problems ?? [])
615
+ problems.push(`chatgpt: ${p}`);
575
616
  for (const s of result.syndicates) {
576
617
  for (const g of s.grants ?? [])
577
618
  for (const p of g.hostProblems ?? [])
@@ -607,7 +648,7 @@ export function renderDoctor(result, opts = {}) {
607
648
  // xAI's key beside Moonshot).
608
649
  const providerBits = (result.providers ?? providerPaths()).map((r) => {
609
650
  const mark = !r.funded ? `${c.red}✗${c.reset}` : r.transport === 'gateway' ? `${c.yellow}◇${c.reset}` : `${c.green}✓${c.reset}`;
610
- const how = !r.funded ? `${c.dim}${r.keyEnv} not set${c.reset}` : r.transport === 'gateway' ? `${c.dim}via gateway:${r.gateway}${c.reset}` : r.platform === 'local' ? `${c.dim}local${c.reset}` : `${c.dim}${r.platform}${c.reset}`;
651
+ const how = !r.funded ? `${c.dim}${r.keyEnv} not set${c.reset}` : r.transport === 'gateway' ? `${c.dim}via gateway:${r.gateway}${c.reset}` : r.platform === 'local' ? `${c.dim}local${c.reset}` : r.credential === 'chatgpt-signin' ? `${c.dim}ChatGPT sign-in, local only${c.reset}` : `${c.dim}${r.platform}${c.reset}`;
611
652
  return `${mark} ${r.label} ${how}`;
612
653
  });
613
654
  lines.push('providers ' + providerBits.join(' '));
@@ -632,6 +673,23 @@ export function renderDoctor(result, opts = {}) {
632
673
  for (const p of sv.problems)
633
674
  lines.push(` ${c.red}✗ ${p}${c.reset}`);
634
675
  }
676
+ if (result.chatgpt) {
677
+ // Presence and the file's path, never a token (ADR 0126).
678
+ const g = result.chatgpt;
679
+ const mark = g.problems.length ? `${c.red}✗${c.reset}` : g.routesOpenAi ? `${c.green}✓${c.reset}` : `${c.dim}·${c.reset}`;
680
+ const state = !g.enabled
681
+ ? `${CHATGPT_SIGNIN_ENV}=off, ignored`
682
+ : !g.present
683
+ ? 'not signed in'
684
+ : !g.signedIn
685
+ ? 'signed out (run melchizedek-setup --chatgpt-signin)'
686
+ : g.routesOpenAi
687
+ ? 'signed in · carries OpenAI ids · local only'
688
+ : 'signed in · not used (OPENAI_API_KEY or another OpenAI endpoint wins)';
689
+ lines.push(`chatgpt ${mark} ${state} ${c.dim}· ${g.file}${c.reset}`);
690
+ for (const p of g.problems)
691
+ lines.push(` ${c.red}✗ ${p}${c.reset}`);
692
+ }
635
693
  if (result.oauth) {
636
694
  // Names only: which variables are set, never their values (ADR 0114).
637
695
  const set = Object.entries(result.oauth.set).map(([name, on]) => `${name} ${on ? 'set' : 'unset'}`).join(' · ');
@@ -3,6 +3,8 @@ export type { DispatchConfig } from './dispatch.ts';
3
3
  import type { SkillsConfig } from './tools/skillToolset.ts';
4
4
  import type { OpenApiConfig } from './tools/openapiTools.ts';
5
5
  import type { McpAuthConfig } from './tools/oauthTools.ts';
6
+ import type { McpTransportKind } from './tools/mcpTransports.ts';
7
+ import type { McpServerConfig } from './tools/mcpToolFactory.ts';
6
8
  import type { ContextConfig, ExampleConfig } from './compile.ts';
7
9
  export type { OpenApiConfig } from './tools/openapiTools.ts';
8
10
  export type { SkillsConfig } from './tools/skillToolset.ts';
@@ -153,6 +155,10 @@ export interface AgentYamlConfig {
153
155
  mcp_tools?: string[];
154
156
  /** The OAuth grant the MCP server takes (ADR 0112): each user's, or the server's own. */
155
157
  mcp_auth?: McpAuthConfig;
158
+ /** How mcp_server_url is reached: auto (the default), streamable_http or sse (ADR 0124). */
159
+ mcp_transport?: McpTransportKind;
160
+ /** Several MCP servers, each with its tools, grant and transport (ADR 0124). Not with mcp_server_url. */
161
+ mcp_servers?: McpServerConfig[];
156
162
  /** Answers when `model` fails provider-side, or its provider's circuit is open (ADR 0044). */
157
163
  fallback_model?: string;
158
164
  /**