create-academic-research 0.1.13 → 0.1.15

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.
@@ -1,11 +1,14 @@
1
- import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
- import { join, relative } from "node:path";
1
+ import { existsSync } from "node:fs";
2
+ import { appendFile, chmod, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import YAML from "yaml";
4
6
  import { assertKnownAgentTarget, AUTO_AGENT, DEFAULT_AGENT, normalizeAgentTarget, SUPPORTED_SKILL_AGENT_TARGETS } from "./agents.js";
5
7
  import { defaultRunner } from "./runner.js";
6
- import { AGENT_STACK, presetMcpServers } from "./stack.js";
8
+ import { AGENT_STACK, mcpModeLabel, mcpRecommendedMode, mcpServerModeKeys, mcpSupportedModeLabels, normalizeMcpMode, presetMcpServers, resolveMcpServer } from "./stack.js";
7
9
  import { formatMcpDotenv, listMcpEnvironmentEntries } from "./mcp-env.js";
8
10
  import { probeMcpServerList } from "./mcp-probe.js";
11
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
9
12
  export { formatMcpDotenv, listMcpEnvironmentEntries };
10
13
  export { mergeMcpEnvironment, readMcpEnvironmentFile } from "./mcp-env.js";
11
14
  export { DEFAULT_AGENT, SUPPORTED_SKILL_AGENT_TARGETS };
@@ -21,13 +24,16 @@ export async function readCapabilities(root) {
21
24
  }
22
25
  }
23
26
  export async function writeCapabilities(root, state) {
27
+ const mcpServers = [...(state.mcp_servers ?? [])];
24
28
  const next = {
25
29
  agent: assertKnownAgentTarget(state.agent),
26
30
  preset: state.preset ?? "default",
27
31
  scope: "project-local",
28
- mcp_servers: [...(state.mcp_servers ?? [])]
32
+ mcp_servers: mcpServers,
33
+ mcp_server_modes: normalizeMcpServerModeMap(state.mcp_server_modes ?? {}, mcpServers),
34
+ mcp_server_remote: normalizeMcpServerRemoteMap(state.mcp_server_remote ?? {}, mcpServers, state.mcp_server_modes ?? {})
29
35
  };
30
- await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(next), "utf8");
36
+ await writeFile(join(root, "configs/capabilities.yaml"), YAML.stringify(serializeCapabilityState(next)), "utf8");
31
37
  await writeCapabilityProfile(root, next);
32
38
  await writeMcpSetup(root, next);
33
39
  await writeMcpSnippet(root, next);
@@ -42,7 +48,9 @@ export async function initializeCapabilities(root, options = {}) {
42
48
  await writeCapabilities(root, {
43
49
  agent: options.agent,
44
50
  preset,
45
- mcp_servers: mcpServers
51
+ mcp_servers: mcpServers,
52
+ mcp_server_modes: {},
53
+ mcp_server_remote: {}
46
54
  });
47
55
  }
48
56
  export async function buildSkillInstallCommands(root, preset = "default", options = {}) {
@@ -96,6 +104,7 @@ export async function installSkills(root, preset = "default", options = {}, runn
96
104
  preset
97
105
  });
98
106
  }
107
+ await recordSkillPresetLock(root, preset, agent, "install");
99
108
  return { ok: true, count: commands.length };
100
109
  }
101
110
  export async function installSkillIds(root, skills, options = {}, runner = defaultRunner) {
@@ -112,6 +121,7 @@ export async function installSkillIds(root, skills, options = {}, runner = defau
112
121
  agent
113
122
  });
114
123
  }
124
+ await recordExplicitSkillLock(root, selectedSkills, agent, "install");
115
125
  return { ok: true, count: commands.length, skills: selectedSkills };
116
126
  }
117
127
  export async function listInstalledSkills(root) {
@@ -162,20 +172,37 @@ export async function removeSkills(root, skills, runner = defaultRunner) {
162
172
  "-y"
163
173
  ], { cwd: root });
164
174
  await removeSkillsFromLock(root, skills);
175
+ await recordRemovedSkillLock(root, skills);
165
176
  return { ok: true, count: skills.length };
166
177
  }
167
178
  export async function updateSkills(root, runner = defaultRunner) {
168
179
  await runner.run(["npm", "exec", "--yes", "--package", "skills", "--", "skills", "update", "--project", "-y"], { cwd: root });
180
+ await recordSkillUpdateLock(root);
169
181
  return { ok: true };
170
182
  }
171
183
  export async function enableMcpServers(root, servers, options = {}) {
172
184
  assertKnownMcpServers(servers);
173
185
  const state = await readCapabilities(root);
174
186
  const selected = dedupe([...(state.mcp_servers ?? []), ...servers]);
187
+ const mcpServerModes = { ...(state.mcp_server_modes ?? {}) };
188
+ const mcpServerRemote = { ...(state.mcp_server_remote ?? {}) };
189
+ for (const server of servers) {
190
+ const mode = normalizeMcpMode(server, options.mode ?? state.mcp_server_modes?.[server]);
191
+ if (options.mode)
192
+ mcpServerModes[server] = mode;
193
+ if (mode === "remote-custom") {
194
+ mcpServerRemote[server] = normalizeRemoteConfig(server, options.remote ?? state.mcp_server_remote?.[server]);
195
+ }
196
+ else {
197
+ delete mcpServerRemote[server];
198
+ }
199
+ }
175
200
  await writeCapabilities(root, {
176
201
  ...state,
177
202
  agent: assertKnownAgentTarget(options.agent ?? state.agent),
178
- mcp_servers: selected
203
+ mcp_servers: selected,
204
+ mcp_server_modes: mcpServerModes,
205
+ mcp_server_remote: mcpServerRemote
179
206
  });
180
207
  return { ok: true, servers: selected };
181
208
  }
@@ -184,50 +211,74 @@ export async function disableMcpServers(root, servers, options = {}) {
184
211
  const state = await readCapabilities(root);
185
212
  const blocked = new Set(servers);
186
213
  const selected = (state.mcp_servers ?? []).filter((server) => !blocked.has(server));
214
+ const mcpServerModes = { ...(state.mcp_server_modes ?? {}) };
215
+ const mcpServerRemote = { ...(state.mcp_server_remote ?? {}) };
216
+ for (const server of servers)
217
+ delete mcpServerModes[server];
218
+ for (const server of servers)
219
+ delete mcpServerRemote[server];
187
220
  await writeCapabilities(root, {
188
221
  ...state,
189
222
  agent: assertKnownAgentTarget(options.agent ?? state.agent),
190
- mcp_servers: selected
223
+ mcp_servers: selected,
224
+ mcp_server_modes: mcpServerModes,
225
+ mcp_server_remote: mcpServerRemote
191
226
  });
192
227
  return { ok: true, servers: selected };
193
228
  }
194
- export function mcpToolCommands(servers, key = "install_command") {
229
+ export function mcpToolCommands(servers, key = "install_command", modes = {}) {
195
230
  assertKnownMcpServers(servers);
196
231
  const commands = [];
197
232
  for (const server of servers) {
198
- const rawCommand = AGENT_STACK.mcp_servers[server]?.[key];
233
+ const rawCommand = resolveMcpServer(server, modes[server])?.[key];
199
234
  if (rawCommand)
200
235
  commands.push(splitCommand(rawCommand));
201
236
  }
202
237
  return commands;
203
238
  }
204
- export function mcpToolCommandTexts(servers, key = "install_command") {
239
+ export function mcpToolCommandTexts(servers, key = "install_command", modes = {}) {
205
240
  assertKnownMcpServers(servers);
206
241
  const commands = [];
207
242
  for (const server of servers) {
208
- const rawCommand = AGENT_STACK.mcp_servers[server]?.[key];
243
+ const rawCommand = resolveMcpServer(server, modes[server])?.[key];
209
244
  if (rawCommand)
210
245
  commands.push(rawCommand);
211
246
  }
212
247
  return commands;
213
248
  }
214
249
  export async function installMcpTools(root, servers, runner = defaultRunner) {
215
- const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
250
+ const state = await readCapabilities(root);
251
+ const selected = servers.length > 0 ? servers : state.mcp_servers ?? [];
216
252
  assertKnownMcpServers(selected);
217
- const commands = mcpToolCommands(selected, "install_command");
253
+ const modes = selectedMcpServerModes(state, selected);
254
+ const skipped = [];
255
+ const commands = mcpToolCommands(selected, "install_command", modes);
218
256
  for (const command of commands) {
219
257
  await runner.run(command, { cwd: root });
220
258
  }
221
- return { ok: true, count: commands.length };
259
+ for (const serverName of selected) {
260
+ const server = resolveMcpServer(serverName, modes[serverName]);
261
+ if (!server.install_command)
262
+ skipped.push(mcpInstallSkip(serverName, server));
263
+ }
264
+ return { ok: true, count: commands.length, skipped };
222
265
  }
223
266
  export async function uninstallMcpTools(root, servers, runner = defaultRunner) {
224
- const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
267
+ const state = await readCapabilities(root);
268
+ const selected = servers.length > 0 ? servers : state.mcp_servers ?? [];
225
269
  assertKnownMcpServers(selected);
226
- const commands = mcpToolCommands(selected, "uninstall_command");
270
+ const modes = selectedMcpServerModes(state, selected);
271
+ const skipped = [];
272
+ const commands = mcpToolCommands(selected, "uninstall_command", modes);
227
273
  for (const command of commands) {
228
274
  await runner.run(command, { cwd: root });
229
275
  }
230
- return { ok: true, count: commands.length };
276
+ for (const serverName of selected) {
277
+ const server = resolveMcpServer(serverName, modes[serverName]);
278
+ if (!server.uninstall_command)
279
+ skipped.push(mcpUninstallSkip(serverName, server));
280
+ }
281
+ return { ok: true, count: commands.length, skipped };
231
282
  }
232
283
  export async function doctorMcpServers(root, options = {}) {
233
284
  const errors = [];
@@ -246,6 +297,7 @@ export async function doctorMcpServers(root, options = {}) {
246
297
  };
247
298
  }
248
299
  const enabled = state.mcp_servers ?? [];
300
+ const modes = selectedMcpServerModes(state, enabled);
249
301
  const unknown = enabled.filter((server) => !AGENT_STACK.mcp_servers[server]);
250
302
  if (unknown.length > 0) {
251
303
  errors.push(`unknown MCP server in capabilities: ${unknown.join(", ")}`);
@@ -260,7 +312,7 @@ export async function doctorMcpServers(root, options = {}) {
260
312
  }
261
313
  }
262
314
  catch (error) {
263
- const hasGeneratedServer = enabled.some((server) => AGENT_STACK.mcp_servers[server]?.command);
315
+ const hasGeneratedServer = enabled.some((server) => isSnippetCapable(resolveMcpServerForState(state, server, modes[server])));
264
316
  if (hasGeneratedServer && isMissingFileError(error)) {
265
317
  errors.push(`missing generated MCP snippet: ${snippetPath}`);
266
318
  }
@@ -270,7 +322,7 @@ export async function doctorMcpServers(root, options = {}) {
270
322
  }
271
323
  }
272
324
  for (const name of enabled) {
273
- const server = AGENT_STACK.mcp_servers[name];
325
+ const server = resolveMcpServerForState(state, name, modes[name]);
274
326
  if (!server)
275
327
  continue;
276
328
  for (const envName of server.required_env) {
@@ -286,7 +338,13 @@ export async function doctorMcpServers(root, options = {}) {
286
338
  if (server.local_service) {
287
339
  warnings.push(`${name}: requires local service: ${server.local_service}`);
288
340
  }
289
- if (!server.command) {
341
+ if (server.connection_mode === "manual-local") {
342
+ const lock = await readCapabilityLock(root);
343
+ if (lock.mcp[name]?.setup?.status !== "ready") {
344
+ warnings.push(`${name}: local setup not complete; run npm run mcp:setup -- ${name} --mode local --env-file .env.local`);
345
+ }
346
+ }
347
+ if (!isSnippetCapable(server)) {
290
348
  warnings.push(`${name}: manual setup only; no generated client command`);
291
349
  continue;
292
350
  }
@@ -300,28 +358,64 @@ export async function probeMcpServers(root, servers, options = {}) {
300
358
  const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
301
359
  const env = options.env ?? process.env;
302
360
  const timeoutMs = options.timeoutMs ?? 5000;
303
- return probeMcpServerList(root, selected, env, timeoutMs, options.clientVersion);
361
+ const state = await readCapabilities(root);
362
+ const modes = Object.fromEntries(selected.map((serverName) => [
363
+ serverName,
364
+ normalizeMcpMode(serverName, options.mode ?? state.mcp_server_modes?.[serverName])
365
+ ]));
366
+ const resolvedServers = Object.fromEntries(selected.map((serverName) => [serverName, resolveMcpServerForState(state, serverName, modes[serverName])]));
367
+ const result = await probeMcpServerList(root, selected, env, timeoutMs, options.clientVersion, modes, resolvedServers);
368
+ await updateCapabilityLock(root, (lock) => {
369
+ for (const item of result.results) {
370
+ const server = resolveMcpServerForState(state, item.server, modes[item.server]);
371
+ const entry = ensureLockMcpEntry(lock, item.server, server);
372
+ entry.probe = { status: item.status, detail: item.detail, updated_at: nowIso() };
373
+ }
374
+ });
375
+ return result;
304
376
  }
305
377
  async function writeMcpSnippet(root, state) {
378
+ const snippet = renderMcpSnippet(state);
379
+ const outputDir = join(root, "docs/agent/generated");
380
+ await mkdir(outputDir, { recursive: true });
381
+ await removeInactiveMcpSnippets(outputDir, snippet.fileName);
382
+ await writeFile(join(outputDir, snippet.fileName), snippet.content, "utf8");
383
+ }
384
+ export function renderMcpSnippet(state) {
306
385
  const servers = {};
386
+ const modes = selectedMcpServerModes(state, state.mcp_servers ?? []);
307
387
  for (const name of state.mcp_servers ?? []) {
308
- const server = AGENT_STACK.mcp_servers[name];
309
- if (!server?.command)
388
+ const server = resolveMcpServerForState(state, name, modes[name]);
389
+ if (server.connection_mode === "remote-curated" && server.hosted_url) {
390
+ servers[name] = { url: server.hosted_url, type: "streamable-http" };
310
391
  continue;
311
- servers[name] = {
312
- command: server.command,
313
- args: server.args
314
- };
392
+ }
393
+ if (server.connection_mode === "remote-custom") {
394
+ const remote = state.mcp_server_remote?.[name];
395
+ if (remote?.url)
396
+ servers[name] = { url: remote.url, type: remote.transport };
397
+ if (remote?.url_env)
398
+ servers[name] = { urlEnv: remote.url_env, type: remote.transport };
399
+ if (remote?.bearer_token_env_var && servers[name]) {
400
+ servers[name].bearerTokenEnvVar = remote.bearer_token_env_var;
401
+ }
402
+ continue;
403
+ }
404
+ if (!server.command)
405
+ continue;
406
+ servers[name] = { command: server.command, args: server.args };
315
407
  if (Object.keys(server.env).length > 0)
316
408
  servers[name].env = server.env;
317
409
  }
318
- const outputDir = join(root, "docs/agent/generated");
319
- const outputFile = mcpSnippetFileName(state.agent);
320
- await mkdir(outputDir, { recursive: true });
321
- await removeInactiveMcpSnippets(outputDir, outputFile);
322
- await writeFile(join(outputDir, outputFile), `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`, "utf8");
410
+ return {
411
+ fileName: mcpSnippetFileName(state.agent),
412
+ content: `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`
413
+ };
323
414
  }
324
415
  async function writeCapabilityProfile(root, state) {
416
+ await writeFile(join(root, "docs/agent/capability-profile.md"), renderCapabilityProfile(state), "utf8");
417
+ }
418
+ export function renderCapabilityProfile(state) {
325
419
  const lines = [
326
420
  "# Agent Capability Profile",
327
421
  "",
@@ -345,22 +439,29 @@ async function writeCapabilityProfile(root, state) {
345
439
  lines.push("- No MCP servers enabled.");
346
440
  }
347
441
  else {
442
+ const modes = selectedMcpServerModes(state, state.mcp_servers);
348
443
  for (const name of state.mcp_servers) {
349
- const server = AGENT_STACK.mcp_servers[name];
350
- const status = server?.command ? server.execution_mode : "manual setup";
444
+ const server = resolveMcpServerForState(state, name, modes[name]);
445
+ const status = mcpModeLabel(name, modes[name]);
351
446
  lines.push(`- \`${name}\` (${status}): ${server?.smoke_test ?? "Smoke-test before use."}`);
352
447
  }
353
448
  }
354
- lines.push("", "## Rules", "", "- Skill installation is project-local by default.", "- Agent target `universal` installs one shared project-local `.agents/skills` copy.", "- MCP enable/disable changes project records; install/uninstall changes external tools.", "- Keep API keys, tokens, cookies, and browser sessions out of git.", "- Cite repository source records, not raw MCP output alone.", "");
355
- await writeFile(join(root, "docs/agent/capability-profile.md"), lines.join("\n"), "utf8");
449
+ lines.push("", "## Rules", "", "- Skill installation is project-local by default.", "- Agent target `universal` installs one shared project-local `.agents/skills` copy.", "- MCP enable/disable changes selected project records; setup/install/client/probe commands change operational state.", "- Keep API keys, tokens, cookies, and browser sessions out of git.", "- Cite repository source records, not raw MCP output alone.", "");
450
+ return lines.join("\n");
356
451
  }
357
452
  async function writeMcpSetup(root, state) {
453
+ await mkdir(join(root, "docs/agent"), { recursive: true });
454
+ await writeFile(join(root, "docs/agent/mcp-setup.md"), renderMcpSetup(state), "utf8");
455
+ }
456
+ export function renderMcpSetup(state) {
358
457
  const enabled = new Set(state.mcp_servers ?? []);
458
+ const modes = selectedMcpServerModes(state, state.mcp_servers ?? []);
359
459
  const lines = [
360
460
  "# MCP Setup",
361
461
  "",
362
462
  "This file is generated from the project-local academic research capability stack.",
363
463
  "MCP records are configuration snippets and setup guidance; the active MCP client must load the generated snippet before these servers become live tools.",
464
+ "Default CLI output uses plain mode labels: local, remote, custom remote, requires local app, and manual setup. Use `npm run mcp:status -- --verbose` for technical transport details.",
364
465
  "",
365
466
  "## Enabled MCP Servers",
366
467
  ""
@@ -370,22 +471,22 @@ async function writeMcpSetup(root, state) {
370
471
  }
371
472
  else {
372
473
  for (const name of state.mcp_servers) {
373
- const server = AGENT_STACK.mcp_servers[name];
474
+ const server = resolveMcpServerForState(state, name, modes[name]);
374
475
  if (!server)
375
476
  continue;
376
- lines.push(`- \`${name}\` (${server.readiness}, ${server.priority}): ${server.source_need}`, ` - Source: \`${server.source}\``, ` - Execution mode: \`${server.execution_mode}\``, ...(server.hosted_url ? [` - Hosted endpoint: <${server.hosted_url}>`] : []), ` - Runtime: ${formatRuntime(server.command, server.args)}`, ` - Install command: ${server.install_command ? `\`${server.install_command}\`` : "none; runtime-only or manual setup"}`, ...server.setup_commands.map((command) => ` - Setup command: \`${command}\``), ` - Smoke test: ${server.smoke_test}`, ` - Risks: ${server.risks}`);
477
+ lines.push(`- \`${name}\` (${server.readiness}, ${server.priority}): ${server.source_need}`, ` - Source: \`${server.source}\``, ` - Selected mode: \`${mcpModeLabel(name, modes[name])}\``, ` - Technical mode: \`${server.connection_mode}\``, ` - Execution mode: \`${server.execution_mode}\``, ...(server.hosted_url ? [` - Hosted endpoint: <${server.hosted_url}>`] : []), ` - Runtime: ${formatRuntime(server.command, server.args)}`, ` - Install command: ${server.install_command ? `\`${server.install_command}\`` : "none; runtime-only or manual setup"}`, ...server.setup_commands.map((command) => ` - Setup command: \`${command}\``), ` - Smoke test: ${server.smoke_test}`, ` - Risks: ${server.risks}`);
377
478
  appendMcpPrerequisiteLines(lines, server.required_env, server.recommended_env, server.local_service);
378
479
  }
379
480
  }
380
481
  lines.push("", "## Available MCP Catalog", "");
381
482
  for (const [name, server] of Object.entries(AGENT_STACK.mcp_servers)) {
382
483
  const status = enabled.has(name) ? "enabled" : "available";
383
- lines.push(`- \`${name}\` (${status}, ${server.readiness}, ${server.priority}): ${server.source_need}`, ` - Source: \`${server.source}\``, ` - Execution mode: \`${server.execution_mode}\``, ...(server.hosted_url ? [` - Hosted endpoint: <${server.hosted_url}>`] : []), ...server.setup_commands.map((command) => ` - Setup command: \`${command}\``));
384
- appendMcpPrerequisiteLines(lines, server.required_env, server.recommended_env, server.local_service);
484
+ const resolved = enabled.has(name) ? resolveMcpServerForState(state, name, modes[name]) : resolveMcpServer(name, mcpRecommendedMode(name));
485
+ lines.push(`- \`${name}\` (${status}, ${resolved.readiness}, ${resolved.priority}): ${resolved.source_need}`, ` - Source: \`${resolved.source}\``, ` - Recommended mode: \`${mcpModeLabel(name, mcpRecommendedMode(name))}\``, ` - Default mode: \`${mcpModeLabel(name)}\``, ...(server.modes ? [` - Supported modes: ${mcpSupportedModeLabels(name).join(", ")}`] : []), ` - Execution mode: \`${resolved.execution_mode}\``, ...(resolved.hosted_url ? [` - Hosted endpoint: <${resolved.hosted_url}>`] : []), ...mcpModeEndpointLines(name), ...resolved.setup_commands.map((command) => ` - Setup command: \`${command}\``));
486
+ appendMcpPrerequisiteLines(lines, resolved.required_env, resolved.recommended_env, resolved.local_service);
385
487
  }
386
- lines.push("", "## Operating Rules", "", "- Use `.env.example` as a committed reference and put filled secrets in `.env.local`, your shell, or your MCP client secret store.", "- Print a dotenv-style reference with `npm run mcp:env -- --dotenv --all`.", "- Regenerate a dotenv-style reference with `npm run mcp:dotenv`.", "- Pass `--env-file .env.local` to `mcp doctor`, `mcp smoke`, or `mcp probe` when you want the CLI to read explicit local secrets.", "- Keep secrets in your shell, MCP client secret store, or local untracked files; do not commit tokens or API keys.", "- Prefer the smallest enabled MCP set that covers the current research question.", "- Treat MCP output as retrieval metadata. Promote claims into repository source records only after source ingestion and citation audit.", "- Run `npm run mcp:doctor` after changing MCP records or environment variables.", "- Run `npm run mcp:probe -- <server>` only when you intentionally want to start selected MCP server processes.", "");
387
- await mkdir(join(root, "docs/agent"), { recursive: true });
388
- await writeFile(join(root, "docs/agent/mcp-setup.md"), lines.join("\n"), "utf8");
488
+ lines.push("", "## Operating Rules", "", "- Use `.env.example` as a committed reference and put filled secrets in `.env.local`, your shell, or your MCP client secret store.", "- Print a dotenv-style reference with `npm run mcp:env -- --dotenv --all`.", "- Regenerate a dotenv-style reference with `npm run mcp:dotenv`.", "- Discover supported modes with `npm run mcp:modes` or `npm run mcp:modes -- openalex`.", "- Inspect selected-vs-ready lifecycle state with `npm run mcp:status`.", "- Select explicit modes with `npm run mcp:enable -- <server> --mode local` or `--mode remote` where supported.", "- Select a custom remote endpoint with `npm run mcp:enable -- <server> --mode remote-custom --url https://example.com/mcp`; use `--url-env <NAME>` for private URLs.", "- Codex automatic registration supports custom remote `--url`; if you use `--url-env`, register manually because Codex CLI has no `--url-env` option.", "- Explicit `--mode remote-custom` still needs `--url` or `--url-env`; otherwise smoke/probe report `missing-remote-url`.", "- Run manual setup with `npm run mcp:setup -- <server> --mode local --env-file .env.local`.", "- Register supported clients with `npm run mcp:client:add -- <server> --agent codex`; use `--dry-run` to print the command first.", "- Pass `--env-file .env.local` to `mcp doctor`, `mcp smoke`, or `mcp probe` when you want the CLI to read explicit local secrets.", "- Keep secrets in your shell, MCP client secret store, or local untracked files; do not commit tokens or API keys.", "- Non-secret observed setup facts are recorded in `docs/agent/capability-lock.json` when setup, client registration, or probes run.", "- Prefer the smallest enabled MCP set that covers the current research question.", "- Treat MCP output as retrieval metadata. Promote claims into repository source records only after source ingestion and citation audit.", "- Run `npm run mcp:doctor` after changing MCP records or environment variables.", "- Run `npm run mcp:probe -- <server>` only when you intentionally want to start selected local stdio MCP server processes; remote endpoints report configured status without a network probe.", "");
489
+ return lines.join("\n");
389
490
  }
390
491
  async function appendCapabilityLog(root, state) {
391
492
  const logPath = join(root, "wiki/log.md");
@@ -399,6 +500,41 @@ function dedupe(values) {
399
500
  function envHasValue(env, name) {
400
501
  return typeof env[name] === "string" && env[name] !== "";
401
502
  }
503
+ function normalizeRemoteConfig(server, config) {
504
+ if (!config || (!config.url && !config.url_env)) {
505
+ throw new Error(`${server}: remote-custom requires --url or --url-env`);
506
+ }
507
+ if (config.url && config.url_env) {
508
+ throw new Error(`${server}: remote-custom accepts either --url or --url-env, not both`);
509
+ }
510
+ const result = { transport: "streamable-http" };
511
+ if (config.url) {
512
+ try {
513
+ const parsed = new URL(config.url);
514
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
515
+ throw new Error("expected http or https URL");
516
+ }
517
+ }
518
+ catch (error) {
519
+ throw new Error(`${server}: invalid remote MCP URL: ${error instanceof Error ? error.message : String(error)}`);
520
+ }
521
+ result.url = config.url;
522
+ }
523
+ if (config.url_env) {
524
+ assertEnvVarName(config.url_env, `${server}: invalid URL env var name`);
525
+ result.url_env = config.url_env;
526
+ }
527
+ if (config.bearer_token_env_var) {
528
+ assertEnvVarName(config.bearer_token_env_var, `${server}: invalid bearer token env var name`);
529
+ result.bearer_token_env_var = config.bearer_token_env_var;
530
+ }
531
+ return result;
532
+ }
533
+ function assertEnvVarName(name, message) {
534
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
535
+ throw new Error(`${message}: ${name}`);
536
+ }
537
+ }
402
538
  function renderSkillCommand(command, agent) {
403
539
  const normalized = assertKnownAgentTarget(agent);
404
540
  const agentFlag = normalized === AUTO_AGENT ? "" : `--agent '${normalized}'`;
@@ -455,11 +591,788 @@ function appendMcpPrerequisiteLines(lines, requiredEnv, recommendedEnv, localSer
455
591
  if (localService)
456
592
  lines.push(` - Local prerequisite: ${localService}`);
457
593
  }
594
+ function mcpModeEndpointLines(serverName) {
595
+ const lines = [];
596
+ for (const mode of mcpServerModeKeys(serverName)) {
597
+ const server = resolveMcpServer(serverName, mode);
598
+ if (server.hosted_url)
599
+ lines.push(` - Hosted endpoint: <${server.hosted_url}>`);
600
+ }
601
+ return lines;
602
+ }
458
603
  function formatRuntime(command, args) {
459
604
  if (!command)
460
605
  return "manual setup";
461
606
  return `\`${[command, ...args].join(" ")}\``;
462
607
  }
608
+ export async function getMcpLifecycleStatus(root, options = {}) {
609
+ const state = await readCapabilities(root);
610
+ const lock = await readCapabilityLock(root);
611
+ const env = options.env ?? process.env;
612
+ const generatedServers = await readGeneratedMcpServers(root, state);
613
+ const selected = new Set(state.mcp_servers ?? []);
614
+ const rows = [];
615
+ for (const id of Object.keys(AGENT_STACK.mcp_servers)) {
616
+ const modeKey = selected.has(id) ? selectedMcpServerModes(state, [id])[id] : mcpRecommendedMode(id);
617
+ const server = selected.has(id) ? resolveMcpServerForState(state, id, modeKey) : resolveMcpServer(id, modeKey);
618
+ const envStatus = selected.has(id) ? lifecycleEnvStatus(server, env) : "n/a";
619
+ const install = selected.has(id) ? lifecycleInstallStatus(root, id, server, lock) : "n/a";
620
+ const snippet = selected.has(id)
621
+ ? generatedServers.has(id)
622
+ ? "available"
623
+ : isSnippetCapable(server)
624
+ ? "missing"
625
+ : "none"
626
+ : "none";
627
+ const client = lifecycleClientStatus(id, server, selected.has(id), state.agent, lock);
628
+ const probe = selected.has(id) ? lock.mcp[id]?.probe?.status ?? "unknown" : "n/a";
629
+ rows.push({
630
+ id,
631
+ selected: selected.has(id),
632
+ mode: mcpModeLabel(id, modeKey),
633
+ mode_key: modeKey,
634
+ connection_mode: server.connection_mode,
635
+ state: lifecycleState(selected.has(id), envStatus, install, snippet, client, probe),
636
+ env: envStatus,
637
+ install,
638
+ snippet,
639
+ client,
640
+ probe,
641
+ next: lifecycleNextAction(id, server, selected.has(id), envStatus, install, snippet, client, probe)
642
+ });
643
+ }
644
+ return { servers: rows };
645
+ }
646
+ export async function readCapabilityLock(root) {
647
+ try {
648
+ const parsed = JSON.parse(await readFile(capabilityLockPath(root), "utf8"));
649
+ const record = typeof parsed === "object" && parsed !== null ? parsed : {};
650
+ return {
651
+ version: typeof record.version === "number" ? record.version : 1,
652
+ generator: normalizeCapabilityLockGenerator(record.generator),
653
+ mcp: typeof record.mcp === "object" && record.mcp !== null ? record.mcp : {},
654
+ skills: normalizeCapabilityLockSkills(record.skills)
655
+ };
656
+ }
657
+ catch (error) {
658
+ if (isMissingFileError(error))
659
+ return defaultCapabilityLock();
660
+ throw error;
661
+ }
662
+ }
663
+ export async function setupMcpServer(root, serverName, options = {}, runner = defaultRunner) {
664
+ assertKnownMcpServers([serverName]);
665
+ const mode = normalizeMcpMode(serverName, options.mode);
666
+ const server = resolveMcpServer(serverName, mode);
667
+ if (serverName !== "overleaf") {
668
+ const commands = server.setup_commands.length > 0 ? server.setup_commands : [];
669
+ return {
670
+ ok: true,
671
+ server: serverName,
672
+ mode,
673
+ commands,
674
+ created: [],
675
+ warnings: commands.length === 0 ? [`${serverName}: no finite setup command is defined`] : [],
676
+ errors: [],
677
+ next: commands.length > 0 ? commands : [`run npm run mcp:status`]
678
+ };
679
+ }
680
+ const env = options.env ?? process.env;
681
+ const required = ["OVERLEAF_TOKEN", "PROJECT_ID"];
682
+ const missing = required.filter((name) => !envHasValue(env, name));
683
+ const paths = overleafPaths(root);
684
+ const commands = [
685
+ `git clone --depth 1 https://github.com/YounesBensafia/overleaf-mcp-server.git ${paths.relativeServer}`,
686
+ `cd ${paths.relativeServer} && uv sync`,
687
+ `write wrapper ${paths.relativeWrapper}`
688
+ ];
689
+ if (missing.length > 0) {
690
+ return {
691
+ ok: false,
692
+ server: serverName,
693
+ mode,
694
+ commands,
695
+ created: [],
696
+ warnings: [],
697
+ errors: [`overleaf: missing required environment variable(s): ${missing.join(", ")}`],
698
+ next: [`fill ${missing.join(", ")} in ${options.envFile ?? ".env.local"}`]
699
+ };
700
+ }
701
+ if (options.dryRun) {
702
+ return {
703
+ ok: true,
704
+ server: serverName,
705
+ mode,
706
+ commands,
707
+ created: [],
708
+ warnings: [],
709
+ errors: [],
710
+ next: [`run npm run mcp:setup -- overleaf --mode local --env-file ${options.envFile ?? ".env.local"}`]
711
+ };
712
+ }
713
+ await mkdir(paths.wrapperDir, { recursive: true });
714
+ if (!existsSync(paths.serverDir)) {
715
+ await runner.run(["git", "clone", "--depth", "1", "https://github.com/YounesBensafia/overleaf-mcp-server.git", paths.serverDir], {
716
+ cwd: root
717
+ });
718
+ }
719
+ await runner.run(["uv", "sync"], { cwd: paths.serverDir });
720
+ await writeFile(paths.wrapper, renderOverleafWrapper(), "utf8");
721
+ await writeFile(paths.launcher, renderOverleafLauncher(), "utf8");
722
+ await chmod(paths.wrapper, 0o755);
723
+ await chmod(paths.launcher, 0o755);
724
+ await updateCapabilityLock(root, (lock) => {
725
+ const entry = ensureLockMcpEntry(lock, "overleaf", server);
726
+ entry.setup = {
727
+ status: "ready",
728
+ server_path: paths.relativeServer,
729
+ wrapper_path: paths.relativeWrapper,
730
+ env_file: options.envFile ? toPosix(relative(root, resolve(root, options.envFile))) : ".env.local",
731
+ updated_at: nowIso()
732
+ };
733
+ });
734
+ return {
735
+ ok: true,
736
+ server: serverName,
737
+ mode,
738
+ commands,
739
+ created: [paths.relativeWrapper, paths.relativeLauncher],
740
+ warnings: [],
741
+ errors: [],
742
+ next: [
743
+ "npm run mcp:client:add -- overleaf --agent codex",
744
+ "npm run mcp:probe -- overleaf --env-file .env.local"
745
+ ]
746
+ };
747
+ }
748
+ export async function clientAddMcpServer(root, serverName, options = {}, runner = defaultRunner) {
749
+ return clientMcpServer(root, serverName, "add", options, runner);
750
+ }
751
+ export async function clientRemoveMcpServer(root, serverName, options = {}, runner = defaultRunner) {
752
+ return clientMcpServer(root, serverName, "remove", options, runner);
753
+ }
754
+ async function clientMcpServer(root, serverName, action, options, runner) {
755
+ assertKnownMcpServers([serverName]);
756
+ const state = await readCapabilities(root);
757
+ const agent = assertKnownAgentTarget(options.agent ?? state.agent);
758
+ const mode = normalizeMcpMode(serverName, options.mode ?? state.mcp_server_modes[serverName]);
759
+ const server = resolveMcpServerForState(state, serverName, mode);
760
+ if (agent !== "codex") {
761
+ return {
762
+ ok: false,
763
+ server: serverName,
764
+ agent,
765
+ command: [],
766
+ instructions: [`${agent} client registration is manual; load docs/agent/generated/${mcpSnippetFileName(agent)} in the client.`]
767
+ };
768
+ }
769
+ const command = action === "remove"
770
+ ? ["codex", "mcp", "remove", serverName]
771
+ : codexAddCommand(root, serverName, server, state);
772
+ if (action === "add") {
773
+ const readiness = await clientRegistrationReadiness(root, serverName, server, mode, state);
774
+ if (!readiness.ready) {
775
+ return {
776
+ ok: false,
777
+ server: serverName,
778
+ agent,
779
+ command,
780
+ instructions: readiness.instructions
781
+ };
782
+ }
783
+ }
784
+ if (!options.dryRun) {
785
+ try {
786
+ await runner.run(command, { cwd: root });
787
+ await updateCapabilityLock(root, (lock) => {
788
+ const entry = ensureLockMcpEntry(lock, serverName, server);
789
+ entry.clients = entry.clients ?? {};
790
+ entry.clients.codex = { status: action === "add" ? "registered" : "removed", updated_at: nowIso() };
791
+ });
792
+ }
793
+ catch (error) {
794
+ return {
795
+ ok: false,
796
+ server: serverName,
797
+ agent,
798
+ command,
799
+ instructions: [
800
+ `Codex CLI registration failed: ${error instanceof Error ? error.message : String(error)}`,
801
+ `Run manually: ${command.join(" ")}`
802
+ ]
803
+ };
804
+ }
805
+ }
806
+ return {
807
+ ok: true,
808
+ server: serverName,
809
+ agent,
810
+ command,
811
+ instructions: options.dryRun ? [`Dry run only; no client config was changed.`] : []
812
+ };
813
+ }
814
+ function serializeCapabilityState(state) {
815
+ const serialized = {
816
+ agent: state.agent,
817
+ preset: state.preset,
818
+ scope: state.scope,
819
+ mcp_servers: state.mcp_servers
820
+ };
821
+ if (Object.keys(state.mcp_server_modes).length > 0)
822
+ serialized.mcp_server_modes = state.mcp_server_modes;
823
+ if (Object.keys(state.mcp_server_remote).length > 0)
824
+ serialized.mcp_server_remote = state.mcp_server_remote;
825
+ return serialized;
826
+ }
827
+ function normalizeMcpServerModeMap(modes, servers) {
828
+ const selected = new Set(servers);
829
+ const result = {};
830
+ for (const [server, mode] of Object.entries(modes)) {
831
+ if (selected.has(server))
832
+ result[server] = normalizeMcpMode(server, mode);
833
+ }
834
+ return result;
835
+ }
836
+ function normalizeMcpServerRemoteMap(remote, servers, modes) {
837
+ const selected = new Set(servers);
838
+ const result = {};
839
+ for (const [server, config] of Object.entries(remote)) {
840
+ if (!selected.has(server))
841
+ continue;
842
+ const mode = normalizeMcpMode(server, modes[server]);
843
+ if (mode === "remote-custom")
844
+ result[server] = normalizeRemoteConfig(server, config);
845
+ }
846
+ return result;
847
+ }
848
+ function selectedMcpServerModes(state, servers) {
849
+ const modes = {};
850
+ for (const server of servers) {
851
+ modes[server] = normalizeMcpMode(server, state.mcp_server_modes?.[server]);
852
+ }
853
+ return modes;
854
+ }
855
+ export function resolveMcpServerForState(state, serverName, mode) {
856
+ const server = resolveMcpServer(serverName, mode ?? state.mcp_server_modes?.[serverName]);
857
+ if (server.selected_mode !== "remote-custom")
858
+ return server;
859
+ const remote = state.mcp_server_remote?.[serverName];
860
+ const remoteConfigured = Boolean(remote?.url || remote?.url_env);
861
+ return {
862
+ ...server,
863
+ hosted_url: remote?.url ?? "",
864
+ remote_url_env: remote?.url_env,
865
+ remote_configured: remoteConfigured,
866
+ required_env: remote?.url_env ? [...server.required_env, remote.url_env] : server.required_env,
867
+ recommended_env: remote?.bearer_token_env_var
868
+ ? [...server.recommended_env, remote.bearer_token_env_var]
869
+ : server.recommended_env
870
+ };
871
+ }
872
+ async function readGeneratedMcpServers(root, state) {
873
+ const generated = new Set();
874
+ try {
875
+ const rawSnippet = await readFile(join(root, "docs", "agent", "generated", mcpSnippetFileName(state.agent)), "utf8");
876
+ const snippet = JSON.parse(rawSnippet);
877
+ for (const name of Object.keys(snippet.mcpServers ?? {}))
878
+ generated.add(name);
879
+ }
880
+ catch {
881
+ return generated;
882
+ }
883
+ return generated;
884
+ }
885
+ function lifecycleEnvStatus(server, env) {
886
+ if (server.required_env.some((name) => !envHasValue(env, name)))
887
+ return "missing-required";
888
+ if (server.recommended_env.some((name) => !envHasValue(env, name)))
889
+ return "missing-recommended";
890
+ return "ok";
891
+ }
892
+ function lifecycleState(selected, envStatus, install, snippet, client, probe) {
893
+ if (!selected)
894
+ return "not selected";
895
+ if (envStatus === "missing-required")
896
+ return "missing env";
897
+ if (install === "setup needed" || install === "requires local app" || install === "manual setup")
898
+ return install;
899
+ if (snippet === "missing")
900
+ return "setup needed";
901
+ if (client.endsWith(":not-added"))
902
+ return "setup needed";
903
+ if (probe === "unknown")
904
+ return "probe needed";
905
+ if (!isSuccessfulProbeStatus(probe))
906
+ return "probe failed";
907
+ return "ready";
908
+ }
909
+ function lifecycleInstallStatus(root, serverName, server, lock) {
910
+ if (server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom")
911
+ return "remote";
912
+ if (server.connection_mode === "manual-local") {
913
+ const entry = lock.mcp[serverName];
914
+ const setup = entry?.setup;
915
+ if (setup?.status === "ready" &&
916
+ entry?.selected_mode === server.selected_mode &&
917
+ entry?.connection_mode === server.connection_mode &&
918
+ setup.wrapper_path &&
919
+ existsSync(join(root, setup.wrapper_path))) {
920
+ return "ready";
921
+ }
922
+ return "setup needed";
923
+ }
924
+ if (server.connection_mode === "local-service")
925
+ return "requires local app";
926
+ if (server.install_command)
927
+ return "finite-installer";
928
+ if (server.command)
929
+ return "runtime-only";
930
+ return "manual setup";
931
+ }
932
+ function lifecycleClientStatus(serverName, server, selected, agent, lock) {
933
+ if (!selected)
934
+ return "none";
935
+ if (!isSnippetCapable(server))
936
+ return "unsupported";
937
+ const normalizedAgent = assertKnownAgentTarget(agent);
938
+ if (normalizedAgent === "universal")
939
+ return "snippet";
940
+ if (normalizedAgent === "codex") {
941
+ const status = lock.mcp[serverName]?.clients?.codex?.status;
942
+ return status === "registered" ? "codex:registered" : "codex:not-added";
943
+ }
944
+ if (normalizedAgent === "claude-code" || normalizedAgent === "cursor")
945
+ return `${normalizedAgent}:manual`;
946
+ return "unknown";
947
+ }
948
+ function lifecycleNextAction(serverName, server, selected, envStatus, install, snippet, client, probe) {
949
+ if (!selected) {
950
+ const recommended = mcpRecommendedMode(serverName);
951
+ const alternatives = mcpServerModeKeys(serverName).filter((mode) => mode !== recommended);
952
+ const altText = alternatives.length > 0
953
+ ? `, alternative: ${alternatives.map((mode) => mcpModeKeyLabelForAction(mode)).join(", ")}`
954
+ : "";
955
+ return `enable ${serverName}, recommended: ${mcpModeKeyLabelForAction(recommended)}${altText}`;
956
+ }
957
+ if (envStatus === "missing-required") {
958
+ if (server.connection_mode === "manual-local") {
959
+ return `fill ${server.required_env.join(", ")} in .env.local; then run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`;
960
+ }
961
+ return `fill ${server.required_env.join(", ")} in .env.local`;
962
+ }
963
+ if (install === "setup needed" && server.connection_mode === "manual-local") {
964
+ return `run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`;
965
+ }
966
+ if (install === "requires local app") {
967
+ return server.setup_commands[0] ?? `start local service for ${serverName}`;
968
+ }
969
+ if (snippet === "missing")
970
+ return "run npm run update -- --apply";
971
+ if (client.endsWith(":not-added"))
972
+ return `run npm run mcp:client:add -- ${serverName} --agent codex`;
973
+ if (!isSuccessfulProbeStatus(probe))
974
+ return `run npm run mcp:probe -- ${serverName} --env-file .env.local`;
975
+ return "ready";
976
+ }
977
+ function isSuccessfulProbeStatus(probe) {
978
+ return probe === "ok" || probe === "remote-configured";
979
+ }
980
+ function mcpModeKeyLabelForAction(mode) {
981
+ if (mode === "remote-custom")
982
+ return "custom remote";
983
+ if (mode === "remote")
984
+ return "remote";
985
+ if (mode === "manual")
986
+ return "manual setup";
987
+ return "local";
988
+ }
989
+ function mcpInstallSkip(serverName, server) {
990
+ if (server.connection_mode === "manual-local") {
991
+ return {
992
+ server: serverName,
993
+ reason: "manual setup",
994
+ next: `run npm run mcp:setup -- ${serverName} --mode local --env-file .env.local`
995
+ };
996
+ }
997
+ if (server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom") {
998
+ return { server: serverName, reason: "remote", next: "no local installer; register the hosted endpoint with the MCP client" };
999
+ }
1000
+ if (server.connection_mode === "local-service") {
1001
+ return {
1002
+ server: serverName,
1003
+ reason: "requires local app",
1004
+ next: server.setup_commands[0] ? `run ${server.setup_commands[0]}` : "satisfy the local service prerequisite"
1005
+ };
1006
+ }
1007
+ if (server.command) {
1008
+ return { server: serverName, reason: "runtime-only", next: "the MCP client launches it on demand" };
1009
+ }
1010
+ return { server: serverName, reason: "manual", next: "follow docs/agent/mcp-setup.md" };
1011
+ }
1012
+ function mcpUninstallSkip(serverName, server) {
1013
+ const skipped = mcpInstallSkip(serverName, server);
1014
+ return { ...skipped, next: skipped.reason === "runtime-only" ? "no persistent tool was installed" : skipped.next };
1015
+ }
1016
+ function isSnippetCapable(server) {
1017
+ return Boolean(server.command ||
1018
+ (server.connection_mode === "remote-curated" && server.hosted_url) ||
1019
+ server.connection_mode === "remote-custom");
1020
+ }
1021
+ function capabilityLockPath(root) {
1022
+ return join(root, "docs", "agent", "capability-lock.json");
1023
+ }
1024
+ async function updateCapabilityLock(root, update) {
1025
+ const lock = await readCapabilityLock(root);
1026
+ lock.generator = {
1027
+ name: "create-academic-research",
1028
+ version: await currentPackageVersion(),
1029
+ updated_at: nowIso()
1030
+ };
1031
+ update(lock);
1032
+ await mkdir(dirname(capabilityLockPath(root)), { recursive: true });
1033
+ await writeFile(capabilityLockPath(root), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
1034
+ }
1035
+ function defaultCapabilityLock() {
1036
+ return {
1037
+ version: 1,
1038
+ generator: { name: "create-academic-research" },
1039
+ mcp: {},
1040
+ skills: { sources: {}, skills: {} }
1041
+ };
1042
+ }
1043
+ function normalizeCapabilityLockGenerator(value) {
1044
+ const record = typeof value === "object" && value !== null ? value : {};
1045
+ return {
1046
+ name: "create-academic-research",
1047
+ ...(typeof record.version === "string" ? { version: record.version } : {}),
1048
+ ...(typeof record.updated_at === "string" ? { updated_at: record.updated_at } : {})
1049
+ };
1050
+ }
1051
+ function normalizeCapabilityLockSkills(value) {
1052
+ const record = typeof value === "object" && value !== null ? value : {};
1053
+ return {
1054
+ ...(typeof record.preset === "string" ? { preset: record.preset } : {}),
1055
+ ...(typeof record.agent === "string" ? { agent: record.agent } : {}),
1056
+ ...(Array.isArray(record.explicit_skill_ids)
1057
+ ? { explicit_skill_ids: record.explicit_skill_ids.filter((item) => typeof item === "string") }
1058
+ : {}),
1059
+ ...(record.last_action === "install" || record.last_action === "update" || record.last_action === "remove"
1060
+ ? { last_action: record.last_action }
1061
+ : {}),
1062
+ ...(record.status === "ready" || record.status === "updated" || record.status === "removed"
1063
+ ? { status: record.status }
1064
+ : {}),
1065
+ ...(typeof record.updated_at === "string" ? { updated_at: record.updated_at } : {}),
1066
+ sources: normalizeCapabilityLockEntryMap(record.sources),
1067
+ skills: normalizeCapabilityLockEntryMap(record.skills)
1068
+ };
1069
+ }
1070
+ function normalizeCapabilityLockEntryMap(value) {
1071
+ if (typeof value !== "object" || value === null)
1072
+ return {};
1073
+ const result = {};
1074
+ for (const [name, rawEntry] of Object.entries(value)) {
1075
+ if (typeof rawEntry !== "object" || rawEntry === null)
1076
+ continue;
1077
+ const entry = rawEntry;
1078
+ if (typeof entry.source !== "string")
1079
+ continue;
1080
+ result[name] = {
1081
+ source: entry.source,
1082
+ ...(Array.isArray(entry.skill_ids)
1083
+ ? { skill_ids: entry.skill_ids.filter((item) => typeof item === "string") }
1084
+ : {}),
1085
+ action: entry.action === "update" || entry.action === "remove" ? entry.action : "install",
1086
+ status: entry.status === "updated" || entry.status === "removed" ? entry.status : "ready",
1087
+ ...(typeof entry.updated_at === "string" ? { updated_at: entry.updated_at } : {}),
1088
+ ...(typeof entry.root === "string" ? { root: entry.root } : {}),
1089
+ ...(typeof entry.path === "string" ? { path: entry.path } : {})
1090
+ };
1091
+ }
1092
+ return result;
1093
+ }
1094
+ function ensureLockMcpEntry(lock, serverName, server) {
1095
+ const entry = lock.mcp[serverName] ?? {};
1096
+ entry.selected_mode = server.selected_mode;
1097
+ entry.connection_mode = server.connection_mode;
1098
+ lock.mcp[serverName] = entry;
1099
+ return entry;
1100
+ }
1101
+ function overleafPaths(root) {
1102
+ const relativeBase = ".academic-research/mcp/overleaf";
1103
+ const wrapperDir = join(root, relativeBase);
1104
+ return {
1105
+ wrapperDir,
1106
+ serverDir: join(wrapperDir, "server"),
1107
+ wrapper: join(wrapperDir, "run-overleaf-mcp.sh"),
1108
+ launcher: join(wrapperDir, "run-overleaf-mcp.mjs"),
1109
+ relativeServer: `${relativeBase}/server`,
1110
+ relativeWrapper: `${relativeBase}/run-overleaf-mcp.sh`,
1111
+ relativeLauncher: `${relativeBase}/run-overleaf-mcp.mjs`
1112
+ };
1113
+ }
1114
+ function renderOverleafWrapper() {
1115
+ return [
1116
+ "#!/bin/sh",
1117
+ "set -eu",
1118
+ "WRAPPER_DIR=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)",
1119
+ "# .env.local is parsed by the Node launcher; this shell wrapper never evaluates it.",
1120
+ "exec node \"$WRAPPER_DIR/run-overleaf-mcp.mjs\"",
1121
+ ""
1122
+ ].join("\n");
1123
+ }
1124
+ function renderOverleafLauncher() {
1125
+ return [
1126
+ "#!/usr/bin/env node",
1127
+ "import { existsSync, readFileSync } from 'node:fs';",
1128
+ "import { dirname, join, resolve } from 'node:path';",
1129
+ "import { fileURLToPath } from 'node:url';",
1130
+ "import { spawn } from 'node:child_process';",
1131
+ "",
1132
+ "const wrapperDir = dirname(fileURLToPath(import.meta.url));",
1133
+ "const projectRoot = resolve(wrapperDir, '../../..');",
1134
+ "const serverDir = process.env.OVERLEAF_MCP_SERVER_PATH || join(projectRoot, '.academic-research/mcp/overleaf/server');",
1135
+ "const envFile = process.env.ACADEMIC_RESEARCH_MCP_ENV_FILE || join(projectRoot, '.env.local');",
1136
+ "const env = { ...process.env };",
1137
+ "if (existsSync(envFile)) {",
1138
+ " for (const [name, value] of Object.entries(parseDotenv(readFileSync(envFile, 'utf8'), envFile))) {",
1139
+ " if (value || !(name in env)) env[name] = value;",
1140
+ " }",
1141
+ "}",
1142
+ "env.PYTHONPATH = env.PYTHONPATH || '.';",
1143
+ "const child = spawn('uv', ['run', 'src/main.py'], { cwd: serverDir, env, stdio: 'inherit' });",
1144
+ "child.on('error', (error) => {",
1145
+ " console.error(`Failed to launch Overleaf MCP server: ${error.message}`);",
1146
+ " process.exitCode = 1;",
1147
+ "});",
1148
+ "child.on('exit', (code, signal) => {",
1149
+ " if (signal) process.kill(process.pid, signal);",
1150
+ " process.exit(code ?? 1);",
1151
+ "});",
1152
+ "",
1153
+ "function parseDotenv(raw, path) {",
1154
+ " const env = {};",
1155
+ " const lines = raw.split(/\\r?\\n/);",
1156
+ " for (const [index, line] of lines.entries()) {",
1157
+ " let text = line.trim();",
1158
+ " if (!text || text.startsWith('#')) continue;",
1159
+ " if (text.startsWith('export ')) text = text.slice('export '.length).trimStart();",
1160
+ " const equals = text.indexOf('=');",
1161
+ " if (equals === -1) throw new Error(`${path}:${index + 1}: expected KEY=value`);",
1162
+ " const key = text.slice(0, equals).trim();",
1163
+ " if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {",
1164
+ " throw new Error(`${path}:${index + 1}: invalid environment variable name: ${key}`);",
1165
+ " }",
1166
+ " env[key] = parseDotenvValue(text.slice(equals + 1).trim(), path, index + 1);",
1167
+ " }",
1168
+ " return env;",
1169
+ "}",
1170
+ "",
1171
+ "function parseDotenvValue(value, path, line) {",
1172
+ " if (!value) return '';",
1173
+ " const quote = value[0];",
1174
+ " if (quote === \"'\" || quote === '\"') {",
1175
+ " if (!value.endsWith(quote) || value.length === 1) throw new Error(`${path}:${line}: unterminated quoted value`);",
1176
+ " const unquoted = value.slice(1, -1);",
1177
+ " if (quote === \"'\") return unquoted;",
1178
+ " return unquoted",
1179
+ " .replaceAll('\\\\n', '\\n')",
1180
+ " .replaceAll('\\\\r', '\\r')",
1181
+ " .replaceAll('\\\\t', '\\t')",
1182
+ " .replaceAll('\\\\\"', '\"')",
1183
+ " .replaceAll('\\\\\\\\', '\\\\');",
1184
+ " }",
1185
+ " return value.replace(/\\s+#.*$/, '').trimEnd();",
1186
+ "}",
1187
+ ""
1188
+ ].join("\n");
1189
+ }
1190
+ async function clientRegistrationReadiness(root, serverName, server, mode, state) {
1191
+ const remote = state.mcp_server_remote?.[serverName];
1192
+ if (server.connection_mode === "remote-custom" && remote?.url_env) {
1193
+ return {
1194
+ ready: false,
1195
+ instructions: [
1196
+ "Codex CLI does not support URL env vars for remote MCP endpoints.",
1197
+ `Either re-enable this server with \`npm run mcp:enable -- ${serverName} --mode remote-custom --url <url>\` if the endpoint URL may be stored in Codex config, or register it manually with \`codex mcp add ${serverName} --url "$${remote.url_env}"\` from a shell where the env var is set.`
1198
+ ]
1199
+ };
1200
+ }
1201
+ if (serverName !== "overleaf")
1202
+ return { ready: true, instructions: [] };
1203
+ const lock = await readCapabilityLock(root);
1204
+ const setup = lock.mcp.overleaf?.setup;
1205
+ const wrapperPath = setup?.wrapper_path ? join(root, setup.wrapper_path) : "";
1206
+ const ready = setup?.status === "ready" &&
1207
+ lock.mcp.overleaf?.selected_mode === mode &&
1208
+ lock.mcp.overleaf?.connection_mode === server.connection_mode &&
1209
+ Boolean(wrapperPath) &&
1210
+ existsSync(wrapperPath);
1211
+ if (ready)
1212
+ return { ready: true, instructions: [] };
1213
+ return {
1214
+ ready: false,
1215
+ instructions: [
1216
+ "Overleaf setup is not ready. Next: npm run mcp:setup -- overleaf --mode local --env-file .env.local"
1217
+ ]
1218
+ };
1219
+ }
1220
+ function codexAddCommand(root, serverName, server, state) {
1221
+ if ((server.connection_mode === "remote-curated" || server.connection_mode === "remote-custom") && server.hosted_url) {
1222
+ const command = ["codex", "mcp", "add", serverName, "--url", server.hosted_url];
1223
+ const tokenEnv = state.mcp_server_remote?.[serverName]?.bearer_token_env_var;
1224
+ if (tokenEnv)
1225
+ command.push("--bearer-token-env-var", tokenEnv);
1226
+ return command;
1227
+ }
1228
+ if (server.connection_mode === "remote-custom" && state.mcp_server_remote?.[serverName]?.url_env) {
1229
+ return [];
1230
+ }
1231
+ if (!server.command)
1232
+ throw new Error(`${serverName} does not have a command or hosted URL for client registration`);
1233
+ const command = commandForClient(root, server.command);
1234
+ return ["codex", "mcp", "add", serverName, "--", command, ...server.args];
1235
+ }
1236
+ function commandForClient(root, command) {
1237
+ if (!command.includes("/") && !command.includes("\\"))
1238
+ return command;
1239
+ return isAbsolute(command) ? command : join(root, command);
1240
+ }
1241
+ async function recordSkillPresetLock(root, preset, agent, action) {
1242
+ const selected = AGENT_STACK.presets[preset];
1243
+ if (!selected)
1244
+ return;
1245
+ await updateCapabilityLock(root, (lock) => {
1246
+ lock.skills.preset = preset;
1247
+ lock.skills.agent = agent;
1248
+ delete lock.skills.explicit_skill_ids;
1249
+ lock.skills.last_action = action;
1250
+ lock.skills.status = "ready";
1251
+ lock.skills.updated_at = nowIso();
1252
+ for (const bundleName of selected.skill_bundles) {
1253
+ for (const source of skillSourcesForBundle(bundleName)) {
1254
+ lock.skills.sources[source.key] = {
1255
+ source: source.source,
1256
+ skill_ids: source.skillIds,
1257
+ action,
1258
+ status: "ready",
1259
+ updated_at: nowIso()
1260
+ };
1261
+ }
1262
+ }
1263
+ });
1264
+ }
1265
+ async function recordExplicitSkillLock(root, skills, agent, action) {
1266
+ await updateCapabilityLock(root, (lock) => {
1267
+ lock.skills.agent = agent;
1268
+ lock.skills.explicit_skill_ids = skills;
1269
+ lock.skills.last_action = action;
1270
+ lock.skills.status = "ready";
1271
+ lock.skills.updated_at = nowIso();
1272
+ for (const skill of skills) {
1273
+ const source = skillSourceForId(skill);
1274
+ if (!source)
1275
+ continue;
1276
+ lock.skills.skills[skill] = {
1277
+ source,
1278
+ action,
1279
+ status: "ready",
1280
+ updated_at: nowIso()
1281
+ };
1282
+ }
1283
+ });
1284
+ }
1285
+ async function recordSkillUpdateLock(root) {
1286
+ await updateCapabilityLock(root, (lock) => {
1287
+ lock.skills.last_action = "update";
1288
+ lock.skills.status = "updated";
1289
+ lock.skills.updated_at = nowIso();
1290
+ for (const entry of Object.values(lock.skills.sources)) {
1291
+ entry.action = "update";
1292
+ entry.status = "updated";
1293
+ entry.updated_at = nowIso();
1294
+ }
1295
+ for (const entry of Object.values(lock.skills.skills)) {
1296
+ entry.action = "update";
1297
+ entry.status = "updated";
1298
+ entry.updated_at = nowIso();
1299
+ }
1300
+ });
1301
+ }
1302
+ async function recordRemovedSkillLock(root, skills) {
1303
+ await updateCapabilityLock(root, (lock) => {
1304
+ lock.skills.last_action = "remove";
1305
+ lock.skills.status = "removed";
1306
+ lock.skills.updated_at = nowIso();
1307
+ for (const skill of normalizeSkillIds(skills)) {
1308
+ const source = skillSourceForId(skill) ?? lock.skills.skills[skill]?.source ?? "unknown";
1309
+ lock.skills.skills[skill] = {
1310
+ source,
1311
+ action: "remove",
1312
+ status: "removed",
1313
+ updated_at: nowIso()
1314
+ };
1315
+ }
1316
+ });
1317
+ }
1318
+ function skillSourcesForBundle(bundleName) {
1319
+ const bundle = AGENT_STACK.skill_bundles[bundleName];
1320
+ if (!bundle)
1321
+ return [];
1322
+ const selections = new Map();
1323
+ for (const command of bundle.commands) {
1324
+ const match = /\bskills\s+add\s+(\S+)/.exec(command);
1325
+ const source = match?.[1];
1326
+ if (!source)
1327
+ continue;
1328
+ const knownSource = skillSourceEntryForSource(source);
1329
+ const key = knownSource?.key ?? bundleName;
1330
+ const skillIds = skillIdsForBundleCommand(command, source);
1331
+ const existing = selections.get(key);
1332
+ selections.set(key, {
1333
+ key,
1334
+ source,
1335
+ skillIds: [...new Set([...(existing?.skillIds ?? []), ...skillIds])]
1336
+ });
1337
+ }
1338
+ return [...selections.values()];
1339
+ }
1340
+ function skillSourceEntryForSource(source) {
1341
+ for (const [key, entry] of Object.entries(AGENT_STACK.skill_sources)) {
1342
+ if (entry.source === source)
1343
+ return { key, skills: entry.skills };
1344
+ }
1345
+ return undefined;
1346
+ }
1347
+ function skillIdsForBundleCommand(command, source) {
1348
+ const known = skillSourceEntryForSource(source);
1349
+ const tokens = splitCommand(command);
1350
+ const skillFlag = tokens.indexOf("--skill");
1351
+ if (skillFlag === -1)
1352
+ return known?.skills ?? [];
1353
+ const selected = [];
1354
+ for (let index = skillFlag + 1; index < tokens.length; index += 1) {
1355
+ const token = tokens[index];
1356
+ if (token.startsWith("-"))
1357
+ break;
1358
+ if (token === "*")
1359
+ return known?.skills ?? [];
1360
+ selected.push(token);
1361
+ }
1362
+ return selected.length > 0 ? selected : known?.skills ?? [];
1363
+ }
1364
+ function nowIso() {
1365
+ return new Date().toISOString();
1366
+ }
1367
+ async function currentPackageVersion() {
1368
+ try {
1369
+ const parsed = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
1370
+ return typeof parsed.version === "string" ? parsed.version : undefined;
1371
+ }
1372
+ catch {
1373
+ return undefined;
1374
+ }
1375
+ }
463
1376
  async function readCapabilitiesFile(root) {
464
1377
  const path = join(root, "configs/capabilities.yaml");
465
1378
  return normalizeCapabilityState(YAML.parse(await readFile(path, "utf8")));
@@ -500,17 +1413,49 @@ function splitCommand(command) {
500
1413
  }
501
1414
  function normalizeCapabilityState(value) {
502
1415
  const record = typeof value === "object" && value !== null ? value : {};
1416
+ const servers = Array.isArray(record.mcp_servers)
1417
+ ? record.mcp_servers.filter((item) => typeof item === "string")
1418
+ : [];
1419
+ const modeRecord = typeof record.mcp_server_modes === "object" && record.mcp_server_modes !== null
1420
+ ? record.mcp_server_modes
1421
+ : {};
1422
+ const mcpServerModes = {};
1423
+ for (const [server, mode] of Object.entries(modeRecord)) {
1424
+ if (typeof mode === "string" && servers.includes(server)) {
1425
+ mcpServerModes[server] = normalizeMcpMode(server, mode);
1426
+ }
1427
+ }
1428
+ const remoteRecord = typeof record.mcp_server_remote === "object" && record.mcp_server_remote !== null
1429
+ ? record.mcp_server_remote
1430
+ : {};
1431
+ const mcpServerRemote = {};
1432
+ for (const [server, config] of Object.entries(remoteRecord)) {
1433
+ if (!servers.includes(server))
1434
+ continue;
1435
+ if (normalizeMcpMode(server, mcpServerModes[server]) !== "remote-custom")
1436
+ continue;
1437
+ if (typeof config === "object" && config !== null) {
1438
+ mcpServerRemote[server] = normalizeRemoteConfig(server, config);
1439
+ }
1440
+ }
503
1441
  return {
504
1442
  agent: assertKnownAgentTarget(typeof record.agent === "string" ? record.agent : undefined),
505
1443
  preset: typeof record.preset === "string" ? record.preset : "default",
506
1444
  scope: "project-local",
507
- mcp_servers: Array.isArray(record.mcp_servers)
508
- ? record.mcp_servers.filter((item) => typeof item === "string")
509
- : []
1445
+ mcp_servers: servers,
1446
+ mcp_server_modes: mcpServerModes,
1447
+ mcp_server_remote: mcpServerRemote
510
1448
  };
511
1449
  }
512
1450
  function defaultCapabilities() {
513
- return { agent: DEFAULT_AGENT, preset: "default", scope: "project-local", mcp_servers: [] };
1451
+ return {
1452
+ agent: DEFAULT_AGENT,
1453
+ preset: "default",
1454
+ scope: "project-local",
1455
+ mcp_servers: [],
1456
+ mcp_server_modes: {},
1457
+ mcp_server_remote: {}
1458
+ };
514
1459
  }
515
1460
  function isMissingFileError(error) {
516
1461
  return (typeof error === "object" &&