@pbhamri/quartermaster-mcp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/bin/server.js +118 -1
  2. package/package.json +2 -2
package/bin/server.js CHANGED
@@ -300,9 +300,110 @@ function emitPr({ repoPath, profileId, branch = null, title = null, body = null,
300
300
  }
301
301
 
302
302
 
303
+ // ---- Repo-read surface (since v0.3.0) ----
304
+ // Lets a peer's MCP client read files in the repo where the server runs.
305
+ // Default REPO_ROOT = cwd at launch (overridable via QM_REPO_ROOT env).
306
+ // Always read-only; sensitive segments + filename patterns are denied.
307
+ const QM_REPO_ROOT = path.resolve(process.env.QM_REPO_ROOT || process.cwd());
308
+ const DENY_SEGMENTS = new Set([
309
+ ".git","node_modules",".env",".copilot",".agency",".azure",".dotnet",
310
+ ".cache",".docker",".ado_orgs.cache",".azure-devops",".ecs",
311
+ ".playwright-outlook-session",".playwright-teams-session",
312
+ "Application Data","Cookies","Local Settings","My Documents",
313
+ "NetHood","PrintHood","Recent","SendTo","Start Menu","Templates",
314
+ ]);
315
+ const DENY_FILE_PATTERNS = [/\.eml$/i, /^email-.*\.md$/i, /token/i, /secret/i, /credentials/i, /\.pem$/, /\.key$/];
316
+ const REPO_MAX_FILE_BYTES = 200 * 1024;
317
+ const REPO_MAX_SEARCH_HITS = 50;
318
+
319
+ function safeResolve(rel = "") {
320
+ const cleaned = String(rel || "").replace(/^[\\/]+/, "").replace(/\\/g, "/");
321
+ const abs = path.resolve(QM_REPO_ROOT, cleaned);
322
+ if (!abs.startsWith(QM_REPO_ROOT)) throw new Error("path traversal blocked");
323
+ for (const seg of abs.replace(QM_REPO_ROOT, "").split(/[\\/]+/)) {
324
+ if (DENY_SEGMENTS.has(seg)) throw new Error(`access denied (segment "${seg}")`);
325
+ }
326
+ for (const rx of DENY_FILE_PATTERNS) {
327
+ if (rx.test(path.basename(abs))) throw new Error("access denied (sensitive filename pattern)");
328
+ }
329
+ return abs;
330
+ }
331
+
332
+ function repoOverview() {
333
+ let frameworkCount = null;
334
+ const fwDir = path.join(QM_REPO_ROOT, "competitive-intel-agent", "frameworks");
335
+ if (fs.existsSync(fwDir)) frameworkCount = fs.readdirSync(fwDir).filter(f => f.endsWith(".js")).length;
336
+ return {
337
+ server: "quartermaster-mcp",
338
+ version: PKG.version,
339
+ repo_root: QM_REPO_ROOT,
340
+ framework_count: frameworkCount,
341
+ note: "Read-only access to files under repo_root. Excluded: secrets, *.eml, .git, node_modules, browser sessions.",
342
+ overridable_env: "QM_REPO_ROOT",
343
+ };
344
+ }
345
+
346
+ function repoListDir({ dir = "" } = {}) {
347
+ const abs = safeResolve(dir);
348
+ const stat = fs.statSync(abs);
349
+ if (!stat.isDirectory()) return { error: "not a directory", path: dir };
350
+ const entries = fs.readdirSync(abs, { withFileTypes: true })
351
+ .filter(e => !DENY_SEGMENTS.has(e.name))
352
+ .map(e => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" }))
353
+ .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
354
+ return { dir: dir || "/", count: entries.length, entries };
355
+ }
356
+
357
+ function repoReadFile({ file } = {}) {
358
+ if (!file) throw new Error("file is required");
359
+ const abs = safeResolve(file);
360
+ const stat = fs.statSync(abs);
361
+ if (!stat.isFile()) throw new Error("not a file");
362
+ if (stat.size > REPO_MAX_FILE_BYTES) {
363
+ return { file, size: stat.size, truncated: true, content: fs.readFileSync(abs, "utf8").slice(0, REPO_MAX_FILE_BYTES), note: `file is ${stat.size} bytes; only first ${REPO_MAX_FILE_BYTES} returned` };
364
+ }
365
+ return { file, size: stat.size, content: fs.readFileSync(abs, "utf8") };
366
+ }
367
+
368
+ function repoSearch({ query, max = REPO_MAX_SEARCH_HITS } = {}) {
369
+ if (!query) throw new Error("query is required");
370
+ const hits = [];
371
+ const rx = new RegExp(query, "i");
372
+ function walk(dir) {
373
+ if (hits.length >= max) return;
374
+ let entries;
375
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
376
+ for (const e of entries) {
377
+ if (DENY_SEGMENTS.has(e.name)) continue;
378
+ const abs = path.join(dir, e.name);
379
+ if (e.isDirectory()) { walk(abs); continue; }
380
+ if (DENY_FILE_PATTERNS.some(p => p.test(e.name))) continue;
381
+ if (!/\.(md|js|ts|tsx|json|yml|yaml|ps1|html|txt)$/i.test(e.name)) continue;
382
+ try {
383
+ const lines = fs.readFileSync(abs, "utf8").split("\n");
384
+ for (let i = 0; i < lines.length && hits.length < max; i++) {
385
+ if (rx.test(lines[i])) {
386
+ hits.push({ file: path.relative(QM_REPO_ROOT, abs).replace(/\\/g, "/"), line: i + 1, text: lines[i].trim().slice(0, 200) });
387
+ }
388
+ }
389
+ } catch {}
390
+ }
391
+ }
392
+ walk(QM_REPO_ROOT);
393
+ return { query, hit_count: hits.length, hits };
394
+ }
395
+
396
+ function repoRecentSessions({ limit = 10 } = {}) {
397
+ const idx = path.join(QM_REPO_ROOT, "knowledge", "sessions.jsonl");
398
+ if (!fs.existsSync(idx)) return { sessions: [], note: "no knowledge/sessions.jsonl in this repo_root" };
399
+ const lines = fs.readFileSync(idx, "utf8").split("\n").filter(Boolean).slice(-limit);
400
+ return { sessions: lines.map(l => { try { return JSON.parse(l); } catch { return { raw: l }; } }) };
401
+ }
402
+
403
+
303
404
  // ---------------- MCP wiring ----------------
304
405
  const server = new Server(
305
- { name: "quartermaster-mcp", version: "0.1.0" },
406
+ { name: "quartermaster-mcp", version: PKG.version },
306
407
  { capabilities: { tools: {}, resources: {} } }
307
408
  );
308
409
 
@@ -332,6 +433,17 @@ const TOOLS = [
332
433
  title: { type:"string" }, body: { type:"string" },
333
434
  base: { type:"string", default:"main" }, draft: { type:"boolean", default:false }
334
435
  } } },
436
+ // ---- v0.3.0 read-only repo surface ----
437
+ { name: "repo_overview", description: "Returns server metadata: repo_root, framework_count (if a competitive-intel-agent/frameworks dir exists), exclusion rules. No args.",
438
+ inputSchema: { type: "object", properties: {} } },
439
+ { name: "repo_list_dir", description: "Lists files and folders in a path under repo_root. Args: dir (relative path, default '/').",
440
+ inputSchema: { type: "object", properties: { dir: { type: "string" } } } },
441
+ { name: "repo_read_file", description: "Returns the text content of a file under repo_root (200 KB cap; secrets / *.eml / browser sessions blocked).",
442
+ inputSchema: { type: "object", required: ["file"], properties: { file: { type: "string" } } } },
443
+ { name: "repo_search", description: "Case-insensitive regex search across .md/.js/.ts/.json/.yml/.ps1/.html/.txt files under repo_root. Args: query (required), max (default 50).",
444
+ inputSchema: { type: "object", required: ["query"], properties: { query: { type: "string" }, max: { type: "integer" } } } },
445
+ { name: "repo_recent_sessions", description: "Returns the last N entries from knowledge/sessions.jsonl (cross-model session memory) if present. Args: limit (default 10).",
446
+ inputSchema: { type: "object", properties: { limit: { type: "integer" } } } },
335
447
  ];
336
448
 
337
449
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -348,6 +460,11 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
348
460
  case "qm_apply_profile": result = await timed("qm.apply_profile", { profileId: args.profileId }, async () => applyProfile(args)); break;
349
461
  case "qm_telemetry": result = readTelemetry(args); break;
350
462
  case "qm_emit_pr": result = await timed("qm.emit_pr", { profileId: args.profileId }, async () => emitPr(args)); break;
463
+ case "repo_overview": result = repoOverview(); break;
464
+ case "repo_list_dir": result = repoListDir(args); break;
465
+ case "repo_read_file": result = repoReadFile(args); break;
466
+ case "repo_search": result = repoSearch(args); break;
467
+ case "repo_recent_sessions": result = repoRecentSessions(args); break;
351
468
  default: throw new Error(`unknown tool: ${name}`);
352
469
  }
353
470
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pbhamri/quartermaster-mcp",
3
- "version": "0.2.0",
4
- "description": "MCP server that seeds any repo with the Quartermaster PM kit (profiles, prompts, AGENTS.md, command center). No folder dependency callable from any Copilot/Claude/Cursor session.",
3
+ "version": "0.3.1",
4
+ "description": "MCP server that seeds any repo with the Quartermaster PM kit AND exposes a read-only repo surface (repo_overview, repo_list_dir, repo_read_file, repo_search, repo_recent_sessions). One-command share with any MS PM peer: npx -y @pbhamri/quartermaster-mcp",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "quartermaster-mcp": "bin/server.js"