@pbhamri/quartermaster-mcp 0.2.0 → 0.3.0
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/bin/server.js +117 -0
- package/package.json +2 -2
package/bin/server.js
CHANGED
|
@@ -300,6 +300,107 @@ 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
406
|
{ name: "quartermaster-mcp", version: "0.1.0" },
|
|
@@ -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.
|
|
4
|
-
"description": "MCP server that seeds any repo with the Quartermaster PM kit (
|
|
3
|
+
"version": "0.3.0",
|
|
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"
|