create-academic-research 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ Release notes are generated from merged commits and pull requests in GitHub
4
+ Releases:
5
+
6
+ https://github.com/VincenzoImp/create-academic-research/releases
7
+
8
+ This file intentionally does not duplicate release entries, so the repository
9
+ has one authoritative changelog source.
package/README.md CHANGED
@@ -105,12 +105,14 @@ npx academic-research mcp available
105
105
  npx academic-research mcp commands arxiv
106
106
  npx academic-research mcp env openalex semantic-scholar zotero
107
107
  npx academic-research mcp env --dotenv --all > .env.example
108
+ npx academic-research mcp env --write .env.example --all
108
109
  npx academic-research mcp enable arxiv dblp
109
110
  npx academic-research mcp disable arxiv
110
111
  npx academic-research mcp install arxiv
111
112
  npx academic-research mcp uninstall arxiv
112
- npx academic-research mcp smoke
113
- npx academic-research mcp doctor
113
+ npx academic-research mcp smoke --env-file .env.local
114
+ npx academic-research mcp doctor --env-file .env.local
115
+ npx academic-research mcp probe arxiv --timeout-ms 5000
114
116
  ```
115
117
 
116
118
  ## Command Model
@@ -138,13 +140,14 @@ MCP commands are split by side-effect:
138
140
  | `mcp enabled` | List only enabled MCP server ids. |
139
141
  | `mcp available` | List the local MCP catalog. |
140
142
  | `mcp commands` | Print finite external install commands without running them. Runtime-only `uvx`/`npx` servers may have no install command. |
141
- | `mcp env` | Print required/recommended env vars, hosted endpoints, local prerequisites, and setup commands for selected servers. Use `--dotenv --all` to regenerate `.env.example`. |
143
+ | `mcp env` | Print required/recommended env vars, hosted endpoints, local prerequisites, and setup commands for selected servers. Use `--dotenv --all` to print dotenv content or `--write .env.example --all` to regenerate `.env.example`. |
142
144
  | `mcp enable` | Enable an MCP server in project records and generated snippets. |
143
145
  | `mcp disable` | Remove an MCP server from project records and generated snippets. |
144
146
  | `mcp install` | Run finite external tool install commands for selected MCP servers. It must not launch stdio MCP servers. |
145
147
  | `mcp uninstall` | Run the external uninstall command when one exists. |
146
148
  | `mcp smoke` | Print non-launching readiness diagnostics for enabled or selected MCP servers. |
147
- | `mcp doctor` | Validate enabled MCP records, generated snippets, required env vars, and documented manual prerequisites. |
149
+ | `mcp doctor` | Validate enabled MCP records, generated snippets, required env vars, and documented manual prerequisites. Pass `--env-file .env.local` to read explicit local secrets. |
150
+ | `mcp probe` | Opt-in runtime check that starts selected MCP servers and performs a stdio JSON-RPC handshake. |
148
151
 
149
152
  ## Companion Skills
150
153
 
@@ -194,9 +197,10 @@ Crossref and broad paper-search aggregators are kept as fallback/manual entries
194
197
  until a project explicitly needs them.
195
198
 
196
199
  Generated projects include a committed `.env.example` with empty MCP variables
197
- and ignore filled `.env` or `.env.local` files. `mcp doctor` checks the current
198
- process environment or client-provided secrets; it does not load `.env.local`
199
- automatically.
200
+ and ignore filled `.env` or `.env.local` files. Regenerate the example with
201
+ `mcp env --write .env.example --all`. `mcp doctor`, `mcp smoke`, and
202
+ `mcp probe` check the current process environment unless you explicitly pass
203
+ `--env-file .env.local`.
200
204
 
201
205
  Generated MCP snippets are project documentation and client-ready config, not
202
206
  live tools by themselves. Your MCP client must load the generated snippet, and
@@ -206,6 +210,8 @@ tool install; it deliberately does not launch stdio MCP servers.
206
210
  Use `mcp smoke` for a non-launching readiness pass before wiring a client: it
207
211
  checks required env vars, manual/local-service status, and whether runtime
208
212
  commands are visible on `PATH`.
213
+ Use `mcp probe` only when you intentionally want to start selected MCP server
214
+ processes and verify a real stdio JSON-RPC handshake.
209
215
 
210
216
  ## Validate This Package
211
217
 
@@ -223,8 +229,8 @@ Releases are tag-driven. Update `package.json` and `package-lock.json`, commit
223
229
  the change, create `vX.Y.Z`, and push the tag:
224
230
 
225
231
  ```bash
226
- git tag -a v0.1.9 -m "v0.1.9"
227
- git push origin main v0.1.9
232
+ git tag -a v0.1.11 -m "v0.1.11"
233
+ git push origin main v0.1.11
228
234
  ```
229
235
 
230
236
  Once the GitHub repository is public, the release workflow validates the tag
@@ -1,6 +1,11 @@
1
1
  import { DEFAULT_AGENT, SUPPORTED_SKILL_AGENT_TARGETS } from "./agents.js";
2
2
  import { type Runner } from "./runner.js";
3
3
  import { type McpToolCommandKey } from "./stack.js";
4
+ import { formatMcpDotenv, listMcpEnvironmentEntries } from "./mcp-env.js";
5
+ import { type McpProbeResult as ProbeResult } from "./mcp-probe.js";
6
+ export { formatMcpDotenv, listMcpEnvironmentEntries };
7
+ export { mergeMcpEnvironment, readMcpEnvironmentFile, type McpEnvironmentEntry } from "./mcp-env.js";
8
+ export { type McpProbeResult, type McpProbeServerResult, type McpProbeStatus } from "./mcp-probe.js";
4
9
  export { DEFAULT_AGENT, SUPPORTED_SKILL_AGENT_TARGETS };
5
10
  export interface CapabilityState {
6
11
  agent: string;
@@ -30,11 +35,13 @@ export interface McpDoctorResult {
30
35
  warnings: string[];
31
36
  enabled: string[];
32
37
  }
33
- export interface McpEnvironmentEntry {
34
- server: string;
35
- kind: "required" | "recommended" | "default";
36
- name: string;
37
- value: string;
38
+ export interface McpDoctorOptions {
39
+ env?: NodeJS.ProcessEnv;
40
+ }
41
+ export interface McpProbeOptions {
42
+ env?: NodeJS.ProcessEnv;
43
+ timeoutMs?: number;
44
+ clientVersion?: string;
38
45
  }
39
46
  interface SkillInstallOptions {
40
47
  agent?: string;
@@ -58,15 +65,8 @@ export declare function disableMcpServers(root: string, servers: string[], optio
58
65
  }): Promise<CapabilityCommandResult>;
59
66
  export declare function mcpToolCommands(servers: string[], key?: McpToolCommandKey): string[][];
60
67
  export declare function mcpToolCommandTexts(servers: string[], key?: McpToolCommandKey): string[];
61
- export declare function listMcpEnvironmentEntries(servers: string[], options?: {
62
- requiredOnly?: boolean;
63
- recommendedOnly?: boolean;
64
- }): McpEnvironmentEntry[];
65
- export declare function formatMcpDotenv(servers: string[], options?: {
66
- requiredOnly?: boolean;
67
- recommendedOnly?: boolean;
68
- }): string;
69
68
  export declare function installMcpTools(root: string, servers: string[], runner?: Runner): Promise<CapabilityCommandResult>;
70
69
  export declare function uninstallMcpTools(root: string, servers: string[], runner?: Runner): Promise<CapabilityCommandResult>;
71
- export declare function doctorMcpServers(root: string): Promise<McpDoctorResult>;
70
+ export declare function doctorMcpServers(root: string, options?: McpDoctorOptions): Promise<McpDoctorResult>;
71
+ export declare function probeMcpServers(root: string, servers: string[], options?: McpProbeOptions): Promise<ProbeResult>;
72
72
  export declare function assertKnownMcpServers(servers: string[]): void;
@@ -4,6 +4,10 @@ import YAML from "yaml";
4
4
  import { assertKnownAgentTarget, AUTO_AGENT, DEFAULT_AGENT, normalizeAgentTarget, SUPPORTED_SKILL_AGENT_TARGETS } from "./agents.js";
5
5
  import { defaultRunner } from "./runner.js";
6
6
  import { AGENT_STACK, presetMcpServers } from "./stack.js";
7
+ import { formatMcpDotenv, listMcpEnvironmentEntries } from "./mcp-env.js";
8
+ import { probeMcpServerList } from "./mcp-probe.js";
9
+ export { formatMcpDotenv, listMcpEnvironmentEntries };
10
+ export { mergeMcpEnvironment, readMcpEnvironmentFile } from "./mcp-env.js";
7
11
  export { DEFAULT_AGENT, SUPPORTED_SKILL_AGENT_TARGETS };
8
12
  export async function readCapabilities(root) {
9
13
  try {
@@ -207,50 +211,6 @@ export function mcpToolCommandTexts(servers, key = "install_command") {
207
211
  }
208
212
  return commands;
209
213
  }
210
- export function listMcpEnvironmentEntries(servers, options = {}) {
211
- assertKnownMcpServers(servers);
212
- const entries = [];
213
- for (const serverName of servers) {
214
- const server = AGENT_STACK.mcp_servers[serverName];
215
- if (!options.recommendedOnly) {
216
- for (const envName of server.required_env) {
217
- entries.push({ server: serverName, kind: "required", name: envName, value: "" });
218
- }
219
- }
220
- if (!options.requiredOnly) {
221
- for (const envName of server.recommended_env) {
222
- entries.push({ server: serverName, kind: "recommended", name: envName, value: "" });
223
- }
224
- for (const [envName, value] of Object.entries(server.env)) {
225
- entries.push({ server: serverName, kind: "default", name: envName, value });
226
- }
227
- }
228
- }
229
- return dedupeMcpEnvironmentEntries(entries);
230
- }
231
- export function formatMcpDotenv(servers, options = {}) {
232
- const entries = listMcpEnvironmentEntries(servers, options);
233
- const lines = [
234
- "# Academic research MCP environment example.",
235
- "# Copy to .env.local, your shell profile, or your MCP client secret store.",
236
- "# Do not commit filled secrets. Empty values mean optional or user-supplied.",
237
- ""
238
- ];
239
- let previousServer = "";
240
- for (const entry of entries) {
241
- if (entry.server !== previousServer) {
242
- if (previousServer)
243
- lines.push("");
244
- lines.push(`# ${entry.server} environment`);
245
- previousServer = entry.server;
246
- }
247
- lines.push(`${entry.name}=${dotenvValue(entry.value)}`);
248
- }
249
- if (entries.length === 0) {
250
- lines.push("# No environment variables are required for the selected MCP servers.");
251
- }
252
- return `${lines.join("\n")}\n`;
253
- }
254
214
  export async function installMcpTools(root, servers, runner = defaultRunner) {
255
215
  const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
256
216
  assertKnownMcpServers(selected);
@@ -269,9 +229,10 @@ export async function uninstallMcpTools(root, servers, runner = defaultRunner) {
269
229
  }
270
230
  return { ok: true, count: commands.length };
271
231
  }
272
- export async function doctorMcpServers(root) {
232
+ export async function doctorMcpServers(root, options = {}) {
273
233
  const errors = [];
274
234
  const warnings = [];
235
+ const env = options.env ?? process.env;
275
236
  let state;
276
237
  try {
277
238
  state = await readCapabilitiesFile(root);
@@ -313,12 +274,12 @@ export async function doctorMcpServers(root) {
313
274
  if (!server)
314
275
  continue;
315
276
  for (const envName of server.required_env) {
316
- if (!process.env[envName]) {
277
+ if (!envHasValue(env, envName)) {
317
278
  errors.push(`${name}: missing required environment variable: ${envName}`);
318
279
  }
319
280
  }
320
281
  for (const envName of server.recommended_env) {
321
- if (!process.env[envName]) {
282
+ if (!envHasValue(env, envName)) {
322
283
  warnings.push(`${name}: recommended environment variable not set: ${envName}`);
323
284
  }
324
285
  }
@@ -335,6 +296,12 @@ export async function doctorMcpServers(root) {
335
296
  }
336
297
  return { ok: errors.length === 0, errors, warnings, enabled };
337
298
  }
299
+ export async function probeMcpServers(root, servers, options = {}) {
300
+ const selected = servers.length > 0 ? servers : (await readCapabilities(root)).mcp_servers ?? [];
301
+ const env = options.env ?? process.env;
302
+ const timeoutMs = options.timeoutMs ?? 5000;
303
+ return probeMcpServerList(root, selected, env, timeoutMs, options.clientVersion);
304
+ }
338
305
  async function writeMcpSnippet(root, state) {
339
306
  const servers = {};
340
307
  for (const name of state.mcp_servers ?? []) {
@@ -416,7 +383,7 @@ async function writeMcpSetup(root, state) {
416
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}\``));
417
384
  appendMcpPrerequisiteLines(lines, server.required_env, server.recommended_env, server.local_service);
418
385
  }
419
- 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.", "- Regenerate a dotenv-style reference with `npx academic-research mcp env --dotenv --all`.", "- 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 `npx academic-research mcp doctor` after changing MCP records or environment variables.", "");
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 `npx academic-research mcp env --dotenv --all`.", "- Regenerate a dotenv-style reference with `npx academic-research mcp env --write .env.example --all`.", "- 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 `npx academic-research mcp doctor` after changing MCP records or environment variables.", "- Run `npx academic-research mcp probe <server>` only when you intentionally want to start selected MCP server processes.", "");
420
387
  await mkdir(join(root, "docs/agent"), { recursive: true });
421
388
  await writeFile(join(root, "docs/agent/mcp-setup.md"), lines.join("\n"), "utf8");
422
389
  }
@@ -429,23 +396,8 @@ async function appendCapabilityLog(root, state) {
429
396
  function dedupe(values) {
430
397
  return [...new Set(values)];
431
398
  }
432
- function dedupeMcpEnvironmentEntries(entries) {
433
- const priority = { required: 0, default: 1, recommended: 2 };
434
- const byName = new Map();
435
- for (const entry of entries) {
436
- const previous = byName.get(entry.name);
437
- if (!previous || priority[entry.kind] < priority[previous.kind]) {
438
- byName.set(entry.name, entry);
439
- }
440
- }
441
- return [...byName.values()];
442
- }
443
- function dotenvValue(value) {
444
- if (!value)
445
- return "";
446
- if (/^[A-Za-z0-9_./:-]+$/.test(value))
447
- return value;
448
- return JSON.stringify(value);
399
+ function envHasValue(env, name) {
400
+ return typeof env[name] === "string" && env[name] !== "";
449
401
  }
450
402
  function renderSkillCommand(command, agent) {
451
403
  const normalized = assertKnownAgentTarget(agent);
package/dist/src/cli.js CHANGED
@@ -1,7 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { basename, delimiter, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { assertKnownMcpServers, disableMcpServers, doctorMcpServers, enableMcpServers, DEFAULT_AGENT, installMcpTools, installSkillIds, installSkills, formatMcpDotenv, listInstalledSkills, listMcpEnvironmentEntries, mcpToolCommandTexts, readCapabilities, removeSkills, uninstallMcpTools, updateSkills } from "./capabilities.js";
4
+ import { assertKnownMcpServers, disableMcpServers, doctorMcpServers, enableMcpServers, DEFAULT_AGENT, installMcpTools, installSkillIds, installSkills, formatMcpDotenv, listInstalledSkills, listMcpEnvironmentEntries, mergeMcpEnvironment, mcpToolCommandTexts, probeMcpServers, readCapabilities, readMcpEnvironmentFile, removeSkills, uninstallMcpTools, updateSkills } from "./capabilities.js";
5
5
  import { createProject, doctorProject, renameProject } from "./project.js";
6
6
  import { askCreateOptions } from "./prompts.js";
7
7
  import { AGENT_STACK, presetMcpServers } from "./stack.js";
@@ -21,7 +21,7 @@ const CREATE_FLAGS = flagSchema([
21
21
  const ROOT_FLAGS = flagSchema(["help"], ["root"]);
22
22
  const RENAME_FLAGS = flagSchema(["help"], ["root", "title", "slug", "package"]);
23
23
  const SKILLS_FLAGS = flagSchema(["help"], ["root", "preset", "agent"]);
24
- const MCP_FLAGS = flagSchema(["help", "all", "dotenv", "required", "recommended"], ["root", "agent"]);
24
+ const MCP_FLAGS = flagSchema(["help", "all", "dotenv", "required", "recommended"], ["root", "agent", "env-file", "write", "timeout-ms"]);
25
25
  export async function main(argv = process.argv.slice(2), mode = "create") {
26
26
  try {
27
27
  if (mode === "create")
@@ -171,8 +171,9 @@ async function setupCommand(argv) {
171
171
  console.log("academic-research skills status");
172
172
  console.log("academic-research mcp list");
173
173
  console.log("academic-research mcp env");
174
- console.log("academic-research mcp env --dotenv --all");
174
+ console.log("academic-research mcp env --write .env.example --all");
175
175
  console.log("academic-research mcp smoke");
176
+ console.log("academic-research mcp probe arxiv");
176
177
  console.log("academic-research doctor");
177
178
  return project.ok ? 0 : 1;
178
179
  }
@@ -340,7 +341,7 @@ async function mcpCommand(argv) {
340
341
  return 0;
341
342
  }
342
343
  if (subcommand === "env") {
343
- assertOnlyOptions(parsed.flags, "mcp env", ["root", "all", "dotenv", "required", "recommended"]);
344
+ assertOnlyOptions(parsed.flags, "mcp env", ["root", "all", "dotenv", "required", "recommended", "write"]);
344
345
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
345
346
  if (flagBool(parsed.flags, "required") && flagBool(parsed.flags, "recommended")) {
346
347
  throw new Error("mcp env cannot use --required and --recommended together");
@@ -355,6 +356,13 @@ async function mcpCommand(argv) {
355
356
  requiredOnly: flagBool(parsed.flags, "required"),
356
357
  recommendedOnly: flagBool(parsed.flags, "recommended")
357
358
  };
359
+ const writePath = flagString(parsed.flags, "write");
360
+ if (writePath) {
361
+ const outputPath = resolve(root, writePath);
362
+ writeFileSync(outputPath, formatMcpDotenv(selected, filters), "utf8");
363
+ console.log(`Wrote MCP dotenv environment reference: ${outputPath}`);
364
+ return 0;
365
+ }
358
366
  if (flagBool(parsed.flags, "dotenv")) {
359
367
  process.stdout.write(formatMcpDotenv(selected, filters));
360
368
  return 0;
@@ -394,15 +402,16 @@ async function mcpCommand(argv) {
394
402
  return 0;
395
403
  }
396
404
  if (subcommand === "smoke") {
397
- assertOnlyOptions(parsed.flags, "mcp smoke", ["root"]);
405
+ assertOnlyOptions(parsed.flags, "mcp smoke", ["root", "env-file"]);
398
406
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
407
+ const env = await mcpCommandEnvironment(root, parsed.flags);
399
408
  const state = await readCapabilities(root);
400
409
  const explicitSelection = parsed.positionals.length > 0;
401
410
  const selected = explicitSelection ? parsed.positionals : state.mcp_servers;
402
411
  assertKnownMcpServers(selected);
403
- const failed = printMcpSmokeDiagnostics(selected);
412
+ const failed = printMcpSmokeDiagnostics(selected, env);
404
413
  if (!explicitSelection) {
405
- const result = await doctorMcpServers(root);
414
+ const result = await doctorMcpServers(root, { env });
406
415
  for (const error of result.errors)
407
416
  console.error(`ERROR: ${error}`);
408
417
  for (const warning of result.warnings)
@@ -412,10 +421,11 @@ async function mcpCommand(argv) {
412
421
  return failed ? 1 : 0;
413
422
  }
414
423
  if (subcommand === "doctor") {
415
- assertOnlyOptions(parsed.flags, "mcp doctor", ["root"]);
424
+ assertOnlyOptions(parsed.flags, "mcp doctor", ["root", "env-file"]);
416
425
  const root = resolve(flagString(parsed.flags, "root") ?? ".");
417
426
  assertNoArguments(parsed.positionals, "mcp doctor");
418
- const result = await doctorMcpServers(root);
427
+ const env = await mcpCommandEnvironment(root, parsed.flags);
428
+ const result = await doctorMcpServers(root, { env });
419
429
  for (const error of result.errors)
420
430
  console.error(`ERROR: ${error}`);
421
431
  for (const warning of result.warnings)
@@ -424,6 +434,23 @@ async function mcpCommand(argv) {
424
434
  console.log(`OK: ${result.enabled.length} MCP server(s) enabled.`);
425
435
  return result.ok ? 0 : 1;
426
436
  }
437
+ if (subcommand === "probe") {
438
+ assertOnlyOptions(parsed.flags, "mcp probe", ["root", "all", "env-file", "timeout-ms"]);
439
+ const root = resolve(flagString(parsed.flags, "root") ?? ".");
440
+ const selected = flagBool(parsed.flags, "all")
441
+ ? Object.keys(AGENT_STACK.mcp_servers)
442
+ : parsed.positionals.length > 0
443
+ ? parsed.positionals
444
+ : (await readCapabilities(root)).mcp_servers;
445
+ assertKnownMcpServers(selected);
446
+ const timeoutMs = parseTimeoutMs(flagString(parsed.flags, "timeout-ms"));
447
+ const env = await mcpCommandEnvironment(root, parsed.flags);
448
+ const result = await probeMcpServers(root, selected, { env, timeoutMs, clientVersion: packageVersion });
449
+ console.log("id\tstatus\tdetail");
450
+ for (const item of result.results)
451
+ console.log(`${item.server}\t${item.status}\t${item.detail}`);
452
+ return result.ok ? 0 : 1;
453
+ }
427
454
  throw new Error(`unknown mcp command: ${subcommand}`);
428
455
  }
429
456
  function parseFlags(argv, schema) {
@@ -508,6 +535,24 @@ function assertOnlyOptions(flags, command, allowedOptions) {
508
535
  throw new Error(`${command} does not accept ${unexpected.map((name) => `--${name}`).join(", ")}`);
509
536
  }
510
537
  }
538
+ async function mcpCommandEnvironment(root, flags) {
539
+ const envFile = flagString(flags, "env-file");
540
+ if (!envFile)
541
+ return process.env;
542
+ const fileEnv = await readMcpEnvironmentFile(resolve(root, envFile));
543
+ return mergeMcpEnvironment(process.env, fileEnv);
544
+ }
545
+ function parseTimeoutMs(value) {
546
+ if (value === undefined)
547
+ return 5000;
548
+ if (!/^[0-9]+$/.test(value))
549
+ throw new Error(`--timeout-ms must be a positive integer, got: ${value}`);
550
+ const timeoutMs = Number(value);
551
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 120000) {
552
+ throw new Error("--timeout-ms must be between 100 and 120000");
553
+ }
554
+ return timeoutMs;
555
+ }
511
556
  export function formatInteractiveCreateGuide() {
512
557
  const presetLines = Object.entries(AGENT_STACK.presets).map(([name, preset]) => ` ${name.padEnd(10)} ${preset.description}`);
513
558
  return [
@@ -535,7 +580,9 @@ export function formatInteractiveCreateGuide() {
535
580
  " MCP installers are optional and run only finite installer commands.",
536
581
  " MCP execution modes are explicit: uvx-runtime, npx-runtime, local-service, manual, or fallback.",
537
582
  " Use `academic-research mcp env <server>` to inspect env vars and local prerequisites.",
538
- " Use `academic-research mcp env --dotenv --all` to regenerate a committed env example.",
583
+ " Use `academic-research mcp env --dotenv --all` to print a committed env example.",
584
+ " Use `academic-research mcp env --write .env.example --all` to regenerate a committed env example.",
585
+ " Use `academic-research mcp doctor --env-file .env.local` to check explicit local secrets.",
539
586
  ""
540
587
  ].join("\n");
541
588
  }
@@ -626,31 +673,37 @@ function printSkillsHelp() {
626
673
  }
627
674
  function printMcpHelp() {
628
675
  console.log([
629
- "Usage: academic-research mcp <list|enabled|available|commands|env|enable|disable|install|uninstall|smoke|doctor> [servers...]",
676
+ "Usage: academic-research mcp <list|enabled|available|commands|env|enable|disable|install|uninstall|smoke|doctor|probe> [servers...]",
630
677
  "",
631
678
  "Manage MCP records, readiness checks, and finite external MCP tool installs.",
632
679
  "",
633
680
  "Examples:",
634
681
  " academic-research mcp env openalex semantic-scholar",
635
682
  " academic-research mcp env --dotenv --all > .env.example",
683
+ " academic-research mcp env --write .env.example --all",
684
+ " academic-research mcp doctor --env-file .env.local",
636
685
  " academic-research mcp smoke",
686
+ " academic-research mcp probe arxiv --timeout-ms 5000",
637
687
  "",
638
688
  "Options:",
639
689
  " --root <path> Project root for project-state commands.",
640
690
  " --agent <id> Agent for enable/disable generated snippets.",
641
691
  " --all Select all catalog MCP servers for mcp env.",
642
692
  " --dotenv Print mcp env as dotenv content.",
693
+ " --write <path> Write mcp env dotenv content to a file.",
694
+ " --env-file <path> Read local env values for mcp smoke, doctor, and probe.",
695
+ " --timeout-ms <ms> Per-server probe timeout. Default: 5000.",
643
696
  " --required Print only required env vars for mcp env.",
644
697
  " --recommended Print only recommended/default env vars for mcp env.",
645
698
  " -h, --help Show this help."
646
699
  ].join("\n"));
647
700
  }
648
- function printMcpSmokeDiagnostics(servers) {
701
+ function printMcpSmokeDiagnostics(servers, env = process.env) {
649
702
  let failed = false;
650
703
  console.log("id\tstatus\truntime\tcheck");
651
704
  for (const name of servers) {
652
705
  const server = AGENT_STACK.mcp_servers[name];
653
- const missingRequired = server.required_env.filter((envName) => !process.env[envName]);
706
+ const missingRequired = server.required_env.filter((envName) => !env[envName]);
654
707
  if (missingRequired.length > 0)
655
708
  failed = true;
656
709
  const runtime = server.command ? [server.command, ...server.args].join(" ") : "manual setup";
@@ -658,7 +711,7 @@ function printMcpSmokeDiagnostics(servers) {
658
711
  if (missingRequired.length > 0) {
659
712
  status = `missing-required-env:${missingRequired.join(",")}`;
660
713
  }
661
- else if (server.command && commandExists(server.command)) {
714
+ else if (server.command && commandExists(server.command, env)) {
662
715
  status = "runtime-found";
663
716
  }
664
717
  else if (server.command) {
@@ -704,14 +757,14 @@ function printMcpEnvironment(servers, options = {}) {
704
757
  console.log(`${name}\tnone\t-`);
705
758
  }
706
759
  }
707
- function commandExists(command) {
760
+ function commandExists(command, env = process.env) {
708
761
  if (!command)
709
762
  return false;
710
763
  if (command.includes("/") || command.includes("\\"))
711
764
  return existsSync(command);
712
- const pathValue = process.env.PATH ?? "";
765
+ const pathValue = env.PATH ?? "";
713
766
  const extensions = process.platform === "win32"
714
- ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
767
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
715
768
  : [""];
716
769
  for (const directory of pathValue.split(delimiter).filter(Boolean)) {
717
770
  for (const extension of extensions) {
@@ -0,0 +1,16 @@
1
+ export interface McpEnvironmentEntry {
2
+ server: string;
3
+ kind: "required" | "recommended" | "default";
4
+ name: string;
5
+ value: string;
6
+ }
7
+ export declare function listMcpEnvironmentEntries(servers: string[], options?: {
8
+ requiredOnly?: boolean;
9
+ recommendedOnly?: boolean;
10
+ }): McpEnvironmentEntry[];
11
+ export declare function formatMcpDotenv(servers: string[], options?: {
12
+ requiredOnly?: boolean;
13
+ recommendedOnly?: boolean;
14
+ }): string;
15
+ export declare function readMcpEnvironmentFile(path: string): Promise<Record<string, string>>;
16
+ export declare function mergeMcpEnvironment(baseEnv?: NodeJS.ProcessEnv, fileEnv?: Record<string, string>): NodeJS.ProcessEnv;
@@ -0,0 +1,122 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { AGENT_STACK } from "./stack.js";
3
+ export function listMcpEnvironmentEntries(servers, options = {}) {
4
+ assertKnownMcpServers(servers);
5
+ const entries = [];
6
+ for (const serverName of servers) {
7
+ const server = AGENT_STACK.mcp_servers[serverName];
8
+ if (!options.recommendedOnly) {
9
+ for (const envName of server.required_env) {
10
+ entries.push({ server: serverName, kind: "required", name: envName, value: "" });
11
+ }
12
+ }
13
+ if (!options.requiredOnly) {
14
+ for (const envName of server.recommended_env) {
15
+ entries.push({ server: serverName, kind: "recommended", name: envName, value: "" });
16
+ }
17
+ for (const [envName, value] of Object.entries(server.env)) {
18
+ entries.push({ server: serverName, kind: "default", name: envName, value });
19
+ }
20
+ }
21
+ }
22
+ return dedupeMcpEnvironmentEntries(entries);
23
+ }
24
+ export function formatMcpDotenv(servers, options = {}) {
25
+ const entries = listMcpEnvironmentEntries(servers, options);
26
+ const lines = [
27
+ "# Academic research MCP environment example.",
28
+ "# Copy to .env.local, your shell profile, or your MCP client secret store.",
29
+ "# Do not commit filled secrets. Empty values mean optional or user-supplied.",
30
+ ""
31
+ ];
32
+ let previousServer = "";
33
+ for (const entry of entries) {
34
+ if (entry.server !== previousServer) {
35
+ if (previousServer)
36
+ lines.push("");
37
+ lines.push(`# ${entry.server} environment`);
38
+ previousServer = entry.server;
39
+ }
40
+ lines.push(`${entry.name}=${dotenvValue(entry.value)}`);
41
+ }
42
+ if (entries.length === 0) {
43
+ lines.push("# No environment variables are required for the selected MCP servers.");
44
+ }
45
+ return `${lines.join("\n")}\n`;
46
+ }
47
+ export async function readMcpEnvironmentFile(path) {
48
+ return parseDotenv(await readFile(path, "utf8"), path);
49
+ }
50
+ export function mergeMcpEnvironment(baseEnv = process.env, fileEnv = {}) {
51
+ const merged = { ...baseEnv };
52
+ for (const [name, value] of Object.entries(fileEnv)) {
53
+ if (value || !(name in merged))
54
+ merged[name] = value;
55
+ }
56
+ return merged;
57
+ }
58
+ function assertKnownMcpServers(servers) {
59
+ const unknown = servers.filter((server) => !AGENT_STACK.mcp_servers[server]);
60
+ if (unknown.length > 0) {
61
+ throw new Error(`unknown MCP server: ${unknown.join(", ")}`);
62
+ }
63
+ }
64
+ function dedupeMcpEnvironmentEntries(entries) {
65
+ const priority = { required: 0, default: 1, recommended: 2 };
66
+ const byName = new Map();
67
+ for (const entry of entries) {
68
+ const previous = byName.get(entry.name);
69
+ if (!previous || priority[entry.kind] < priority[previous.kind]) {
70
+ byName.set(entry.name, entry);
71
+ }
72
+ }
73
+ return [...byName.values()];
74
+ }
75
+ function dotenvValue(value) {
76
+ if (!value)
77
+ return "";
78
+ if (/^[A-Za-z0-9_./:-]+$/.test(value))
79
+ return value;
80
+ return JSON.stringify(value);
81
+ }
82
+ function parseDotenv(raw, path) {
83
+ const env = {};
84
+ const lines = raw.split(/\r?\n/);
85
+ for (const [index, line] of lines.entries()) {
86
+ let text = line.trim();
87
+ if (!text || text.startsWith("#"))
88
+ continue;
89
+ if (text.startsWith("export "))
90
+ text = text.slice("export ".length).trimStart();
91
+ const equals = text.indexOf("=");
92
+ if (equals === -1) {
93
+ throw new Error(`${path}:${index + 1}: expected KEY=value`);
94
+ }
95
+ const key = text.slice(0, equals).trim();
96
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
97
+ throw new Error(`${path}:${index + 1}: invalid environment variable name: ${key}`);
98
+ }
99
+ env[key] = parseDotenvValue(text.slice(equals + 1).trim(), path, index + 1);
100
+ }
101
+ return env;
102
+ }
103
+ function parseDotenvValue(value, path, line) {
104
+ if (!value)
105
+ return "";
106
+ const quote = value[0];
107
+ if (quote === "'" || quote === '"') {
108
+ if (!value.endsWith(quote) || value.length === 1) {
109
+ throw new Error(`${path}:${line}: unterminated quoted value`);
110
+ }
111
+ const unquoted = value.slice(1, -1);
112
+ if (quote === "'")
113
+ return unquoted;
114
+ return unquoted
115
+ .replaceAll("\\n", "\n")
116
+ .replaceAll("\\r", "\r")
117
+ .replaceAll("\\t", "\t")
118
+ .replaceAll('\\"', '"')
119
+ .replaceAll("\\\\", "\\");
120
+ }
121
+ return value.replace(/\s+#.*$/, "").trimEnd();
122
+ }
@@ -0,0 +1,11 @@
1
+ export type McpProbeStatus = "ok" | "manual" | "missing-env" | "runtime-missing" | "startup-failed" | "protocol-error" | "timeout";
2
+ export interface McpProbeServerResult {
3
+ server: string;
4
+ status: McpProbeStatus;
5
+ detail: string;
6
+ }
7
+ export interface McpProbeResult {
8
+ ok: boolean;
9
+ results: McpProbeServerResult[];
10
+ }
11
+ export declare function probeMcpServerList(root: string, servers: string[], env: NodeJS.ProcessEnv, timeoutMs: number, clientVersion?: string): Promise<McpProbeResult>;
@@ -0,0 +1,177 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { AGENT_STACK } from "./stack.js";
5
+ export async function probeMcpServerList(root, servers, env, timeoutMs, clientVersion = "unknown") {
6
+ assertKnownMcpServers(servers);
7
+ const results = [];
8
+ for (const name of servers) {
9
+ const server = AGENT_STACK.mcp_servers[name];
10
+ const missingRequired = server.required_env.filter((envName) => !envHasValue(env, envName));
11
+ if (missingRequired.length > 0) {
12
+ results.push({ server: name, status: "missing-env", detail: missingRequired.join(",") });
13
+ continue;
14
+ }
15
+ if (!server.command) {
16
+ results.push({ server: name, status: "manual", detail: server.local_service || "manual setup only" });
17
+ continue;
18
+ }
19
+ if (!commandExists(server.command, env)) {
20
+ results.push({ server: name, status: "runtime-missing", detail: server.command });
21
+ continue;
22
+ }
23
+ results.push(await probeMcpServerProcess(root, name, server.command, server.args, { ...server.env, ...env }, timeoutMs, clientVersion));
24
+ }
25
+ return { ok: results.every((result) => result.status === "ok"), results };
26
+ }
27
+ function assertKnownMcpServers(servers) {
28
+ const unknown = servers.filter((server) => !AGENT_STACK.mcp_servers[server]);
29
+ if (unknown.length > 0) {
30
+ throw new Error(`unknown MCP server: ${unknown.join(", ")}`);
31
+ }
32
+ }
33
+ function envHasValue(env, name) {
34
+ return typeof env[name] === "string" && env[name] !== "";
35
+ }
36
+ function commandExists(command, env = process.env) {
37
+ if (!command)
38
+ return false;
39
+ if (command.includes("/") || command.includes("\\"))
40
+ return existsSync(command);
41
+ const pathValue = env.PATH ?? "";
42
+ const extensions = process.platform === "win32"
43
+ ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
44
+ : [""];
45
+ for (const directory of pathValue.split(delimiter).filter(Boolean)) {
46
+ for (const extension of extensions) {
47
+ const hasExtension = extension && command.toLowerCase().endsWith(extension.toLowerCase());
48
+ const candidate = join(directory, hasExtension ? command : `${command}${extension}`);
49
+ if (existsSync(candidate))
50
+ return true;
51
+ }
52
+ }
53
+ return false;
54
+ }
55
+ async function probeMcpServerProcess(root, server, command, args, env, timeoutMs, clientVersion) {
56
+ return new Promise((resolve) => {
57
+ let settled = false;
58
+ let stderr = "";
59
+ let stdout = Buffer.alloc(0);
60
+ const child = spawn(command, args, {
61
+ cwd: root,
62
+ env,
63
+ stdio: ["pipe", "pipe", "pipe"]
64
+ });
65
+ const timer = setTimeout(() => finish("timeout", `${timeoutMs}ms`), timeoutMs);
66
+ function finish(status, detail) {
67
+ if (settled)
68
+ return;
69
+ settled = true;
70
+ clearTimeout(timer);
71
+ child.kill();
72
+ resolve({ server, status, detail: detail || "-" });
73
+ }
74
+ child.on("error", (error) => {
75
+ finish(error.code === "ENOENT" ? "runtime-missing" : "startup-failed", error.message);
76
+ });
77
+ child.on("close", (code) => {
78
+ if (!settled)
79
+ finish("startup-failed", stderr.trim() || `process exited with code ${code ?? "unknown"}`);
80
+ });
81
+ child.stdin.on("error", (error) => {
82
+ finish("startup-failed", error.message);
83
+ });
84
+ child.stderr.on("data", (chunk) => {
85
+ stderr += chunk.toString("utf8");
86
+ });
87
+ child.stdout.on("data", (chunk) => {
88
+ stdout = Buffer.concat([stdout, chunk]);
89
+ try {
90
+ for (const message of drainMcpMessages()) {
91
+ handleMcpProbeMessage(message);
92
+ }
93
+ }
94
+ catch (error) {
95
+ finish("protocol-error", error instanceof Error ? error.message : String(error));
96
+ }
97
+ });
98
+ try {
99
+ child.stdin.write(encodeMcpMessage({
100
+ jsonrpc: "2.0",
101
+ id: 1,
102
+ method: "initialize",
103
+ params: {
104
+ protocolVersion: "2025-06-18",
105
+ capabilities: {},
106
+ clientInfo: { name: "academic-research-cli", version: clientVersion }
107
+ }
108
+ }));
109
+ }
110
+ catch (error) {
111
+ finish("startup-failed", error instanceof Error ? error.message : String(error));
112
+ }
113
+ function handleMcpProbeMessage(message) {
114
+ if (message.id === 1) {
115
+ if (message.error) {
116
+ finish("protocol-error", formatJsonRpcError(message.error));
117
+ return;
118
+ }
119
+ child.stdin.write(encodeMcpMessage({
120
+ jsonrpc: "2.0",
121
+ method: "notifications/initialized",
122
+ params: {}
123
+ }));
124
+ child.stdin.write(encodeMcpMessage({
125
+ jsonrpc: "2.0",
126
+ id: 2,
127
+ method: "tools/list",
128
+ params: {}
129
+ }));
130
+ }
131
+ else if (message.id === 2) {
132
+ if (message.error) {
133
+ finish("protocol-error", formatJsonRpcError(message.error));
134
+ return;
135
+ }
136
+ const result = typeof message.result === "object" && message.result !== null
137
+ ? message.result
138
+ : {};
139
+ const toolCount = Array.isArray(result.tools) ? result.tools.length : "unknown";
140
+ finish("ok", `tools=${toolCount}`);
141
+ }
142
+ }
143
+ function drainMcpMessages() {
144
+ const messages = [];
145
+ while (true) {
146
+ const separator = stdout.indexOf("\r\n\r\n");
147
+ if (separator === -1)
148
+ return messages;
149
+ const header = stdout.slice(0, separator).toString("utf8");
150
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
151
+ if (!match)
152
+ throw new Error("missing Content-Length header");
153
+ const length = Number(match[1]);
154
+ const bodyStart = separator + 4;
155
+ const bodyEnd = bodyStart + length;
156
+ if (stdout.length < bodyEnd)
157
+ return messages;
158
+ const body = stdout.slice(bodyStart, bodyEnd).toString("utf8");
159
+ stdout = stdout.slice(bodyEnd);
160
+ const parsed = JSON.parse(body);
161
+ if (typeof parsed !== "object" || parsed === null)
162
+ throw new Error("MCP response is not an object");
163
+ messages.push(parsed);
164
+ }
165
+ }
166
+ });
167
+ }
168
+ function encodeMcpMessage(message) {
169
+ const body = JSON.stringify(message);
170
+ return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`;
171
+ }
172
+ function formatJsonRpcError(error) {
173
+ if (typeof error === "object" && error !== null && "message" in error) {
174
+ return String(error.message);
175
+ }
176
+ return JSON.stringify(error);
177
+ }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import YAML from "yaml";
@@ -97,6 +97,7 @@ export async function createProject(options) {
97
97
  }
98
98
  await mkdir(dirname(target), { recursive: true });
99
99
  await copyDirectory(templateRoot, target);
100
+ await writeGeneratedGitignore(target);
100
101
  await personalizeProject(target, { title, slug, packageName, profile: options.profile ?? "academic-general" });
101
102
  await writeGeneratedPackageJson(target, { slug });
102
103
  await writeAgentStack(target);
@@ -122,7 +123,7 @@ export async function renameProject(root, options) {
122
123
  profile: config.project.profile,
123
124
  previousPackage
124
125
  });
125
- await writeGeneratedPackageJson(target, { slug });
126
+ await writeGeneratedPackageJson(target, { slug, preserveExistingSpec: true });
126
127
  return { root: target, title, slug, packageName };
127
128
  }
128
129
  export async function doctorProject(root) {
@@ -130,6 +131,7 @@ export async function doctorProject(root) {
130
131
  const errors = [];
131
132
  const required = [
132
133
  "README.md",
134
+ ".gitignore",
133
135
  ".env.example",
134
136
  "package.json",
135
137
  "pyproject.toml",
@@ -213,11 +215,13 @@ async function personalizeProject(root, { title, slug, packageName, profile, pre
213
215
  }
214
216
  }
215
217
  }
216
- async function writeGeneratedPackageJson(root, { slug }) {
218
+ async function writeGeneratedPackageJson(root, { slug, preserveExistingSpec = false }) {
217
219
  const path = join(root, "package.json");
218
220
  const data = await readJson(path);
219
221
  const existingSpec = data.devDependencies?.["create-academic-research"];
220
- const packageSpec = process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ?? existingSpec ?? "0.1.9";
222
+ const packageSpec = process.env.CREATE_ACADEMIC_RESEARCH_PACKAGE_SPEC ??
223
+ (preserveExistingSpec ? existingSpec : undefined) ??
224
+ await currentPackageVersion();
221
225
  data.name = slug;
222
226
  data.devDependencies = {
223
227
  ...(data.devDependencies ?? {}),
@@ -225,6 +229,20 @@ async function writeGeneratedPackageJson(root, { slug }) {
225
229
  };
226
230
  await writeJson(path, data);
227
231
  }
232
+ async function writeGeneratedGitignore(root) {
233
+ const source = join(root, "_gitignore");
234
+ if (await exists(source)) {
235
+ await writeFile(join(root, ".gitignore"), await readFile(source, "utf8"), "utf8");
236
+ await rm(source);
237
+ }
238
+ }
239
+ async function currentPackageVersion() {
240
+ const packageJson = await readJson(join(packageRoot, "package.json"));
241
+ if (!packageJson.version) {
242
+ throw new Error("package.json missing version");
243
+ }
244
+ return packageJson.version;
245
+ }
228
246
  async function writeAgentStack(root) {
229
247
  await writeFile(join(root, "configs/agent-stack.yaml"), YAML.stringify(AGENT_STACK), "utf8");
230
248
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-academic-research",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Create and manage agent-ready academic research repositories.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "dist",
37
37
  "template",
38
38
  "README.md",
39
+ "CHANGELOG.md",
39
40
  "LICENSE",
40
41
  "SECURITY.md"
41
42
  ],
@@ -61,14 +61,15 @@ npx academic-research skills install source-ingestion sota-literature-review
61
61
  npx academic-research skills list
62
62
  npx academic-research skills status
63
63
  npx academic-research setup
64
+ npx academic-research mcp env --write .env.example --all
64
65
  npx academic-research mcp list
65
66
  npx academic-research mcp env openalex semantic-scholar zotero
66
- npx academic-research mcp env --dotenv --all > .env.example
67
67
  npx academic-research mcp enable arxiv dblp
68
68
  npx academic-research mcp commands arxiv
69
69
  npx academic-research mcp install arxiv
70
- npx academic-research mcp smoke
71
- npx academic-research mcp doctor
70
+ npx academic-research mcp smoke --env-file .env.local
71
+ npx academic-research mcp doctor --env-file .env.local
72
+ npx academic-research mcp probe arxiv --timeout-ms 5000
72
73
  ```
73
74
 
74
75
  `skills list` reports installed project-local skills. `skills presets` reports
@@ -79,16 +80,18 @@ enable optional servers. `mcp install` runs only finite tool installation
79
80
  commands; runtime-only `uvx`/`npx` MCP servers may have no install step and are
80
81
  started later by the MCP client.
81
82
 
82
- `.env.example` is the committed MCP environment reference. Copy it to
83
- `.env.local`, your shell profile, or your MCP client secret store when secrets
84
- are needed. Filled `.env` files are ignored by git. `mcp doctor` checks the
85
- current process environment; it does not automatically load `.env.local`.
83
+ `.env.example` is the committed MCP environment reference. Regenerate it with
84
+ `mcp env --write .env.example --all`. Copy it to `.env.local`, your shell
85
+ profile, or your MCP client secret store when secrets are needed. Filled `.env`
86
+ files are ignored by git. `mcp doctor` checks the current process environment
87
+ unless you explicitly pass `--env-file .env.local`.
86
88
 
87
89
  `setup` prints the current project capability state, installed skill counts,
88
90
  enabled MCP records, and the next onboarding commands without changing files.
89
91
  `mcp smoke` performs a non-launching MCP readiness check: it reports required
90
92
  env vars, local/manual setup, and whether client runtime commands such as `uvx`
91
- or `npx` are available.
93
+ or `npx` are available. `mcp probe` is opt-in and starts selected MCP servers
94
+ for a real stdio JSON-RPC handshake.
92
95
 
93
96
  `default` installs the companion academic research skill package and keeps the
94
97
  MCP records focused on low-friction arXiv discovery. `literature` and `full`
@@ -96,3 +99,5 @@ add DBLP for computer science bibliography. Credentialed, local-service, or
96
99
  domain-specific MCP servers such as OpenAlex, Semantic Scholar, PubMed, Zotero,
97
100
  and Overleaf should be enabled only after reading `docs/agent/mcp-setup.md` and
98
101
  checking their prerequisites with `mcp env`.
102
+
103
+ See `docs/getting-started.md` for the recommended first session workflow.
@@ -0,0 +1,62 @@
1
+ .DS_Store
2
+ node_modules/
3
+ dist/
4
+ .venv/
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.egg-info/
8
+ build/
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ data/raw/**
15
+ !data/raw/
16
+ !data/raw/.gitkeep
17
+ data/interim/**
18
+ !data/interim/
19
+ !data/interim/.gitkeep
20
+ data/processed/**
21
+ !data/processed/
22
+ !data/processed/.gitkeep
23
+ data/external/**
24
+ !data/external/
25
+ !data/external/.gitkeep
26
+ outputs/**
27
+ !outputs/
28
+ !outputs/**/
29
+ !outputs/**/.gitkeep
30
+ analysis_outputs/**
31
+ !analysis_outputs/
32
+ !analysis_outputs/.gitkeep
33
+ debug_outputs/**
34
+ !debug_outputs/
35
+ !debug_outputs/.gitkeep
36
+ repro_outputs/**
37
+ !repro_outputs/
38
+ !repro_outputs/.gitkeep
39
+ train_outputs/**
40
+ !train_outputs/
41
+ !train_outputs/.gitkeep
42
+ explore_outputs/**
43
+ !explore_outputs/
44
+ !explore_outputs/.gitkeep
45
+ artifacts/releases/**
46
+ !artifacts/releases/
47
+ !artifacts/releases/.gitkeep
48
+ artifacts/data/**
49
+ !artifacts/data/
50
+ !artifacts/data/.gitkeep
51
+ artifacts/models/**
52
+ !artifacts/models/
53
+ !artifacts/models/.gitkeep
54
+ artifacts/cache/**
55
+ !artifacts/cache/
56
+ !artifacts/cache/.gitkeep
57
+ docs/agent/generated/*.local.json
58
+ .env
59
+ .env.*
60
+ !.env.example
61
+ !.gitkeep
62
+ !README.md
@@ -19,7 +19,7 @@ store.
19
19
  Regenerate the committed reference from the current MCP catalog with:
20
20
 
21
21
  ```bash
22
- npx academic-research mcp env --dotenv --all > .env.example
22
+ npx academic-research mcp env --write .env.example --all
23
23
  ```
24
24
 
25
25
  Create a private local file when needed:
@@ -29,15 +29,39 @@ cp .env.example .env.local
29
29
  ```
30
30
 
31
31
  Do not commit filled `.env`, `.env.local`, tokens, cookies, or browser sessions.
32
- `mcp doctor` checks the current process environment; it does not automatically
33
- load `.env.local`.
32
+ `mcp doctor`, `mcp smoke`, and `mcp probe` check the current process
33
+ environment unless you explicitly pass `--env-file .env.local`.
34
+
35
+ ```bash
36
+ npx academic-research mcp doctor --env-file .env.local
37
+ npx academic-research mcp smoke --env-file .env.local
38
+ npx academic-research mcp probe arxiv --timeout-ms 5000
39
+ ```
40
+
41
+ ## Client Notes
42
+
43
+ For Codex, Claude Code, Cursor, or another MCP client, load the generated
44
+ snippet that matches the active agent target:
45
+
46
+ - `docs/agent/generated/mcp.json` for the universal/default target.
47
+ - `docs/agent/generated/codex-mcp.json` when the project was created with
48
+ `--agent codex`.
49
+ - `docs/agent/generated/claude-code-mcp.json` when using Claude Code.
50
+ - `docs/agent/generated/cursor-mcp.json` when using Cursor.
51
+
52
+ The generated snippet is project documentation until the client loads it. If a
53
+ client has its own secret store, prefer that store for API keys and tokens. If
54
+ the client inherits shell environment variables, start it from a shell where the
55
+ required variables are already exported.
34
56
 
35
57
  ## Workflow
36
58
 
37
59
  1. Enable only the MCP servers needed for the current research task.
38
60
  2. Inspect prerequisites with `npx academic-research mcp env <server>`.
39
61
  3. Put required secrets in the MCP client secret store, shell, or `.env.local`.
40
- 4. Run `npx academic-research mcp smoke` before wiring the client.
41
- 5. Load the generated snippet in the MCP client.
42
- 6. Treat MCP output as retrieval metadata until it is ingested into repository
62
+ 4. Run `npx academic-research mcp smoke --env-file .env.local`.
63
+ 5. Run `npx academic-research mcp probe <server>` only when you want to start
64
+ the server and verify a real stdio handshake.
65
+ 6. Load the generated snippet in the MCP client.
66
+ 7. Treat MCP output as retrieval metadata until it is ingested into repository
43
67
  source records.
@@ -5,4 +5,9 @@ and known risks here.
5
5
 
6
6
  Use `.env.example` as the committed environment reference. Put filled values in
7
7
  `.env.local`, the shell, or the MCP client secret store. Regenerate the example
8
- with `npx academic-research mcp env --dotenv --all > .env.example`.
8
+ with `npx academic-research mcp env --write .env.example --all`.
9
+
10
+ Use `npx academic-research mcp doctor --env-file .env.local` when you want the
11
+ CLI to read an explicit local env file. Use `npx academic-research mcp probe
12
+ <server>` only when you want to start a selected MCP server and verify a real
13
+ stdio handshake.
@@ -0,0 +1,86 @@
1
+ # Getting Started
2
+
3
+ Use this path for the first working session in a new research repository.
4
+
5
+ ## 1. Check The Repository
6
+
7
+ ```bash
8
+ npm install
9
+ npx academic-research doctor
10
+ npx academic-research setup
11
+ ```
12
+
13
+ `doctor` checks required files and structural contracts. `setup` prints the
14
+ active skill preset, installed skill count, enabled MCP records, and next
15
+ commands without changing files.
16
+
17
+ ## 2. Install Project-Local Skills
18
+
19
+ Install the default academic research skill package:
20
+
21
+ ```bash
22
+ npx academic-research skills install --preset default
23
+ npx academic-research skills status
24
+ ```
25
+
26
+ Use `enhanced` only when the project also needs complementary development,
27
+ document, frontend, testing, and conversion skills:
28
+
29
+ ```bash
30
+ npx academic-research skills install --preset enhanced
31
+ ```
32
+
33
+ ## 3. Prepare MCP Environment
34
+
35
+ Keep `.env.example` committed and empty of real secrets. Put filled values in
36
+ `.env.local`, your shell, or your MCP client secret store.
37
+
38
+ ```bash
39
+ npx academic-research mcp env --write .env.example --all
40
+ cp .env.example .env.local
41
+ npx academic-research mcp env openalex semantic-scholar zotero
42
+ npx academic-research mcp doctor --env-file .env.local
43
+ ```
44
+
45
+ `mcp smoke` is a non-launching readiness check. `mcp probe` is opt-in and starts
46
+ MCP processes for a real stdio handshake.
47
+
48
+ ```bash
49
+ npx academic-research mcp smoke --env-file .env.local
50
+ npx academic-research mcp probe arxiv --timeout-ms 5000
51
+ ```
52
+
53
+ ## 4. Start Source Work
54
+
55
+ Put source originals and metadata in the source layer before synthesis.
56
+
57
+ ```text
58
+ sources/pdfs/ native PDFs
59
+ sources/markdown/ derived Markdown
60
+ sources/metadata/ downloaded metadata or query exports
61
+ sources/bib/ BibTeX and citation audits
62
+ ```
63
+
64
+ Update `sources/source-ledger.csv` whenever a paper, report, dataset, or web
65
+ source becomes evidence for the project.
66
+
67
+ ## 5. Build The First SOTA Pass
68
+
69
+ Use `sota/search-strategy.md` to record search terms, databases, dates, and
70
+ inclusion criteria. Put screened sources in `sota/literature-matrix.csv`, then
71
+ summarize stable conclusions in `sota/synthesis.md`.
72
+
73
+ Do not treat MCP output as final evidence until the relevant source has been
74
+ ingested, deduplicated, and tied to a source record.
75
+
76
+ ## 6. Keep Durable Memory Current
77
+
78
+ Update the wiki when project knowledge changes:
79
+
80
+ - `wiki/log.md`: chronological actions and decisions.
81
+ - `wiki/index.md`: navigation index.
82
+ - `wiki/synthesis.md`: current project-level interpretation.
83
+ - `wiki/open_questions.md`: unresolved questions.
84
+ - `wiki/contradictions.md`: conflicting sources, claims, or runs.
85
+
86
+ Prefer small, source-linked updates over long ungrounded summaries.
@@ -11,11 +11,12 @@
11
11
  "mcp:list": "academic-research mcp list",
12
12
  "mcp:commands": "academic-research mcp commands",
13
13
  "mcp:env": "academic-research mcp env",
14
- "mcp:dotenv": "academic-research mcp env --dotenv --all",
14
+ "mcp:dotenv": "academic-research mcp env --write .env.example --all",
15
15
  "mcp:smoke": "academic-research mcp smoke",
16
- "mcp:doctor": "academic-research mcp doctor"
16
+ "mcp:doctor": "academic-research mcp doctor",
17
+ "mcp:probe": "academic-research mcp probe"
17
18
  },
18
19
  "devDependencies": {
19
- "create-academic-research": "0.1.9"
20
+ "create-academic-research": "0.1.11"
20
21
  }
21
22
  }