feat(mcp-server): add 6 read-only tools for project state queries (#3515)

Add gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures,
and gsd_knowledge tools that parse .gsd/ on disk — no session needed.

Inline lightweight readers in src/readers/ keep the package standalone
(zero new dependencies). 33 new tests, 64 total passing.
This commit is contained in:
Jeremy 2026-04-04 16:41:24 -05:00
parent 099e6f3120
commit 45b606744f
12 changed files with 1951 additions and 5 deletions

View file

@ -15,7 +15,7 @@ const MCP_PKG = '@modelcontextprotocol/sdk';
async function main(): Promise<void> {
const sessionManager = new SessionManager();
// Create the configured MCP server with all 6 tools
// Create the configured MCP server with all 12 tools (6 session + 6 read-only)
const { server } = await createMcpServer(sessionManager);
// Dynamic import for StdioServerTransport (same TS subpath workaround)

View file

@ -1,5 +1,5 @@
/**
* @gsd-build/mcp-server MCP server for GSD orchestration.
* @gsd-build/mcp-server MCP server for GSD orchestration and project state.
*/
export { SessionManager } from './session-manager.js';
@ -12,3 +12,17 @@ export type {
CostAccumulator,
} from './types.js';
export { MAX_EVENTS, INIT_TIMEOUT_MS } from './types.js';
// Read-only state readers (usable without a running session)
export { readProgress } from './readers/state.js';
export type { ProgressResult } from './readers/state.js';
export { readRoadmap } from './readers/roadmap.js';
export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './readers/roadmap.js';
export { readHistory } from './readers/metrics.js';
export type { HistoryResult, MetricsUnit } from './readers/metrics.js';
export { readCaptures } from './readers/captures.js';
export type { CapturesResult, CaptureEntry } from './readers/captures.js';
export { readKnowledge } from './readers/knowledge.js';
export type { KnowledgeResult, KnowledgeEntry } from './readers/knowledge.js';
export { runDoctorLite } from './readers/doctor-lite.js';
export type { DoctorResult, DoctorIssue } from './readers/doctor-lite.js';

View file

@ -0,0 +1,119 @@
// GSD MCP Server — captures reader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readFileSync, existsSync } from 'node:fs';
import { resolveGsdRoot, resolveRootFile } from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CaptureStatus = 'pending' | 'triaged' | 'resolved';
export type CaptureClassification =
| 'quick-task' | 'inject' | 'defer' | 'replan' | 'note' | 'stop' | 'backtrack';
export interface CaptureEntry {
id: string;
text: string;
timestamp: string;
status: CaptureStatus;
classification: CaptureClassification | null;
resolution: string | null;
rationale: string | null;
resolvedAt: string | null;
milestone: string | null;
executed: string | null;
}
export interface CapturesResult {
captures: CaptureEntry[];
counts: {
total: number;
pending: number;
resolved: number;
actionable: number;
};
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
function parseCapturesMarkdown(content: string): CaptureEntry[] {
const entries: CaptureEntry[] = [];
// Split on H3 headers: ### CAP-xxxxxxxx
const sections = content.split(/(?=^### CAP-)/m);
for (const section of sections) {
const idMatch = section.match(/^### (CAP-[\da-f]+)/);
if (!idMatch) continue;
const id = idMatch[1];
const field = (label: string): string | null => {
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, 'i');
const m = section.match(re);
return m ? m[1].trim() : null;
};
const status = (field('Status') ?? 'pending').toLowerCase() as CaptureStatus;
const classification = field('Classification') as CaptureClassification | null;
entries.push({
id,
text: field('Text') ?? '',
timestamp: field('Captured') ?? '',
status,
classification,
resolution: field('Resolution'),
rationale: field('Rationale'),
resolvedAt: field('Resolved'),
milestone: field('Milestone'),
executed: field('Executed'),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
const ACTIONABLE_CLASSIFICATIONS = new Set<string>(['quick-task', 'inject', 'replan']);
export function readCaptures(
projectDir: string,
filter: 'all' | 'pending' | 'actionable' = 'all',
): CapturesResult {
const gsd = resolveGsdRoot(projectDir);
const capturesPath = resolveRootFile(gsd, 'CAPTURES.md');
if (!existsSync(capturesPath)) {
return { captures: [], counts: { total: 0, pending: 0, resolved: 0, actionable: 0 } };
}
const content = readFileSync(capturesPath, 'utf-8');
let captures = parseCapturesMarkdown(content);
// Compute counts before filtering
const counts = {
total: captures.length,
pending: captures.filter((c) => c.status === 'pending').length,
resolved: captures.filter((c) => c.status === 'resolved').length,
actionable: captures.filter(
(c) => c.classification !== null && ACTIONABLE_CLASSIFICATIONS.has(c.classification),
).length,
};
// Apply filter
if (filter === 'pending') {
captures = captures.filter((c) => c.status === 'pending');
} else if (filter === 'actionable') {
captures = captures.filter(
(c) => c.classification !== null && ACTIONABLE_CLASSIFICATIONS.has(c.classification),
);
}
return { captures, counts };
}

View file

@ -0,0 +1,225 @@
// GSD MCP Server — lightweight structural health checks
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { existsSync, readFileSync } from 'node:fs';
import {
resolveGsdRoot,
resolveRootFile,
findMilestoneIds,
resolveMilestoneFile,
resolveMilestoneDir,
findSliceIds,
resolveSliceFile,
findTaskFiles,
} from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type Severity = 'info' | 'warning' | 'error';
export interface DoctorIssue {
severity: Severity;
code: string;
scope: 'project' | 'milestone' | 'slice' | 'task';
unitId: string;
message: string;
file?: string;
}
export interface DoctorResult {
ok: boolean;
issues: DoctorIssue[];
counts: { error: number; warning: number; info: number };
}
// ---------------------------------------------------------------------------
// Check implementations
// ---------------------------------------------------------------------------
function checkProjectLevel(gsdRoot: string, issues: DoctorIssue[]): void {
// PROJECT.md should exist
const projectPath = resolveRootFile(gsdRoot, 'PROJECT.md');
if (!existsSync(projectPath)) {
issues.push({
severity: 'warning',
code: 'missing_project_md',
scope: 'project',
unitId: '',
message: 'PROJECT.md is missing — project lacks a description',
file: projectPath,
});
}
// STATE.md should exist if milestones exist
const milestones = findMilestoneIds(gsdRoot);
if (milestones.length > 0) {
const statePath = resolveRootFile(gsdRoot, 'STATE.md');
if (!existsSync(statePath)) {
issues.push({
severity: 'warning',
code: 'missing_state_md',
scope: 'project',
unitId: '',
message: 'STATE.md is missing — run /gsd status to regenerate',
file: statePath,
});
}
}
}
function checkMilestoneLevel(gsdRoot: string, mid: string, issues: DoctorIssue[]): void {
const mDir = resolveMilestoneDir(gsdRoot, mid);
if (!mDir) {
issues.push({
severity: 'error',
code: 'missing_milestone_dir',
scope: 'milestone',
unitId: mid,
message: `Milestone directory for ${mid} not found`,
});
return;
}
// CONTEXT.md should exist
const ctxPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT');
if (!ctxPath || !existsSync(ctxPath)) {
// Check for draft
const draftPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT-DRAFT');
if (!draftPath || !existsSync(draftPath)) {
issues.push({
severity: 'warning',
code: 'missing_context',
scope: 'milestone',
unitId: mid,
message: `${mid} has no CONTEXT.md — milestone lacks defined scope`,
});
}
}
// ROADMAP.md should exist if slices exist
const sliceIds = findSliceIds(gsdRoot, mid);
if (sliceIds.length > 0) {
const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP');
if (!roadmapPath || !existsSync(roadmapPath)) {
issues.push({
severity: 'warning',
code: 'missing_roadmap',
scope: 'milestone',
unitId: mid,
message: `${mid} has ${sliceIds.length} slices but no ROADMAP.md`,
});
}
}
// Check if all slices done but no SUMMARY
if (sliceIds.length > 0) {
const allDone = sliceIds.every((sid) => {
const tasks = findTaskFiles(gsdRoot, mid, sid);
return tasks.length > 0 && tasks.every((t) => t.hasSummary);
});
const summaryPath = resolveMilestoneFile(gsdRoot, mid, 'SUMMARY');
if (allDone && (!summaryPath || !existsSync(summaryPath))) {
issues.push({
severity: 'error',
code: 'all_slices_done_missing_summary',
scope: 'milestone',
unitId: mid,
message: `${mid} has all slices completed but no SUMMARY.md`,
});
}
}
}
function checkSliceLevel(
gsdRoot: string, mid: string, sid: string, issues: DoctorIssue[],
): void {
const unitId = `${mid}/${sid}`;
// PLAN.md should exist
const planPath = resolveSliceFile(gsdRoot, mid, sid, 'PLAN');
if (!planPath || !existsSync(planPath)) {
issues.push({
severity: 'error',
code: 'missing_slice_plan',
scope: 'slice',
unitId,
message: `${unitId} has no PLAN.md`,
});
}
// Tasks should have plans
const tasks = findTaskFiles(gsdRoot, mid, sid);
for (const task of tasks) {
const taskUnitId = `${unitId}/${task.id}`;
if (!task.hasPlan) {
issues.push({
severity: 'warning',
code: 'missing_task_plan',
scope: 'task',
unitId: taskUnitId,
message: `${taskUnitId} has a summary but no plan file`,
});
}
}
// Check for empty slice (directory exists but no tasks or plan)
if (tasks.length === 0 && (!planPath || !existsSync(planPath))) {
issues.push({
severity: 'warning',
code: 'empty_slice',
scope: 'slice',
unitId,
message: `${unitId} has no plan and no tasks — may be abandoned`,
});
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function runDoctorLite(projectDir: string, scope?: string): DoctorResult {
const gsdRoot = resolveGsdRoot(projectDir);
const issues: DoctorIssue[] = [];
if (!existsSync(gsdRoot)) {
return {
ok: true,
issues: [{
severity: 'info',
code: 'no_gsd_directory',
scope: 'project',
unitId: '',
message: 'No .gsd/ directory found — project not initialized',
}],
counts: { error: 0, warning: 0, info: 1 },
};
}
// Project-level checks
checkProjectLevel(gsdRoot, issues);
// Milestone + slice checks
const milestoneIds = scope
? findMilestoneIds(gsdRoot).filter((id) => id === scope)
: findMilestoneIds(gsdRoot);
for (const mid of milestoneIds) {
checkMilestoneLevel(gsdRoot, mid, issues);
const sliceIds = findSliceIds(gsdRoot, mid);
for (const sid of sliceIds) {
checkSliceLevel(gsdRoot, mid, sid, issues);
}
}
const counts = {
error: issues.filter((i) => i.severity === 'error').length,
warning: issues.filter((i) => i.severity === 'warning').length,
info: issues.filter((i) => i.severity === 'info').length,
};
return { ok: counts.error === 0, issues, counts };
}

View file

@ -0,0 +1,16 @@
// GSD MCP Server — readers barrel export
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
export { resolveGsdRoot, resolveRootFile } from './paths.js';
export { readProgress } from './state.js';
export type { ProgressResult } from './state.js';
export { readRoadmap } from './roadmap.js';
export type { RoadmapResult, MilestoneInfo, SliceInfo, TaskInfo } from './roadmap.js';
export { readHistory } from './metrics.js';
export type { HistoryResult, MetricsUnit } from './metrics.js';
export { readCaptures } from './captures.js';
export type { CapturesResult, CaptureEntry } from './captures.js';
export { readKnowledge } from './knowledge.js';
export type { KnowledgeResult, KnowledgeEntry } from './knowledge.js';
export { runDoctorLite } from './doctor-lite.js';
export type { DoctorResult, DoctorIssue } from './doctor-lite.js';

View file

@ -0,0 +1,111 @@
// GSD MCP Server — knowledge base reader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readFileSync, existsSync } from 'node:fs';
import { resolveGsdRoot, resolveRootFile } from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type KnowledgeType = 'rule' | 'pattern' | 'lesson';
export interface KnowledgeEntry {
id: string;
type: KnowledgeType;
scope: string;
content: string;
addedAt: string;
}
export interface KnowledgeResult {
entries: KnowledgeEntry[];
counts: { rules: number; patterns: number; lessons: number };
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
function parseTableRows(section: string, type: KnowledgeType): KnowledgeEntry[] {
const entries: KnowledgeEntry[] = [];
const lines = section.split('\n');
for (const line of lines) {
if (!line.includes('|')) continue;
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
if (cells.length < 3) continue;
// Skip header/separator
if (cells[0].startsWith('#') || cells[0].startsWith('-')) continue;
const id = cells[0];
if (!/^[KPL]\d+$/i.test(id)) continue;
if (type === 'rule' && cells.length >= 5) {
entries.push({
id, type, scope: cells[1], content: cells[2], addedAt: cells[4] ?? '',
});
} else if (type === 'pattern' && cells.length >= 4) {
entries.push({
id, type, scope: cells[2] ?? '', content: cells[1], addedAt: cells[3] ?? '',
});
} else if (type === 'lesson' && cells.length >= 5) {
entries.push({
id, type, scope: cells[4] ?? '',
content: `${cells[1]} — Root cause: ${cells[2]} — Fix: ${cells[3]}`,
addedAt: '',
});
}
}
return entries;
}
function parseKnowledgeMarkdown(content: string): KnowledgeEntry[] {
const entries: KnowledgeEntry[] = [];
// Find ## Rules section
const rulesMatch = content.match(/## Rules\s*\n([\s\S]*?)(?=\n## |$)/i);
if (rulesMatch) {
entries.push(...parseTableRows(rulesMatch[1], 'rule'));
}
// Find ## Patterns section
const patternsMatch = content.match(/## Patterns\s*\n([\s\S]*?)(?=\n## |$)/i);
if (patternsMatch) {
entries.push(...parseTableRows(patternsMatch[1], 'pattern'));
}
// Find ## Lessons Learned section
const lessonsMatch = content.match(/## Lessons Learned\s*\n([\s\S]*?)(?=\n## |$)/i);
if (lessonsMatch) {
entries.push(...parseTableRows(lessonsMatch[1], 'lesson'));
}
return entries;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function readKnowledge(projectDir: string): KnowledgeResult {
const gsd = resolveGsdRoot(projectDir);
const knowledgePath = resolveRootFile(gsd, 'KNOWLEDGE.md');
if (!existsSync(knowledgePath)) {
return { entries: [], counts: { rules: 0, patterns: 0, lessons: 0 } };
}
const content = readFileSync(knowledgePath, 'utf-8');
const entries = parseKnowledgeMarkdown(content);
return {
entries,
counts: {
rules: entries.filter((e) => e.type === 'rule').length,
patterns: entries.filter((e) => e.type === 'pattern').length,
lessons: entries.filter((e) => e.type === 'lesson').length,
},
};
}

View file

@ -0,0 +1,118 @@
// GSD MCP Server — metrics/history reader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readFileSync, existsSync } from 'node:fs';
import { resolveGsdRoot, resolveRootFile } from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MetricsUnit {
type: string;
id: string;
model: string;
startedAt: number;
finishedAt: number;
tokens: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
cost: number;
toolCalls: number;
apiRequests: number;
}
export interface HistoryResult {
entries: MetricsUnit[];
totals: {
cost: number;
tokens: { input: number; output: number; total: number };
units: number;
durationMs: number;
};
}
// ---------------------------------------------------------------------------
// Parser
// ---------------------------------------------------------------------------
function parseMetricsJson(content: string): MetricsUnit[] {
try {
const data = JSON.parse(content);
if (!data.units || !Array.isArray(data.units)) return [];
return data.units.map((u: Record<string, unknown>) => ({
type: String(u.type ?? 'unknown'),
id: String(u.id ?? ''),
model: String(u.model ?? 'unknown'),
startedAt: Number(u.startedAt ?? 0),
finishedAt: Number(u.finishedAt ?? 0),
tokens: {
input: Number((u.tokens as Record<string, unknown>)?.input ?? 0),
output: Number((u.tokens as Record<string, unknown>)?.output ?? 0),
cacheRead: Number((u.tokens as Record<string, unknown>)?.cacheRead ?? 0),
cacheWrite: Number((u.tokens as Record<string, unknown>)?.cacheWrite ?? 0),
total: Number((u.tokens as Record<string, unknown>)?.total ?? 0),
},
cost: Number(u.cost ?? 0),
toolCalls: Number(u.toolCalls ?? 0),
apiRequests: Number(u.apiRequests ?? 0),
}));
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function readHistory(projectDir: string, limit?: number): HistoryResult {
const gsd = resolveGsdRoot(projectDir);
// metrics.json (primary)
const metricsPath = resolveRootFile(gsd, 'metrics.json');
let units: MetricsUnit[] = [];
if (existsSync(metricsPath)) {
const content = readFileSync(metricsPath, 'utf-8');
units = parseMetricsJson(content);
}
// Sort by startedAt descending (most recent first)
units.sort((a, b) => b.startedAt - a.startedAt);
// Apply limit
if (limit && limit > 0) {
units = units.slice(0, limit);
}
// Compute totals from ALL units (not just limited set)
const allUnits = existsSync(metricsPath)
? parseMetricsJson(readFileSync(metricsPath, 'utf-8'))
: [];
const totals = {
cost: 0,
tokens: { input: 0, output: 0, total: 0 },
units: allUnits.length,
durationMs: 0,
};
for (const u of allUnits) {
totals.cost += u.cost;
totals.tokens.input += u.tokens.input;
totals.tokens.output += u.tokens.output;
totals.tokens.total += u.tokens.total;
totals.durationMs += (u.finishedAt - u.startedAt);
}
// Round cost to 4 decimal places
totals.cost = Math.round(totals.cost * 10000) / 10000;
return { entries: units, totals };
}

View file

@ -0,0 +1,217 @@
// GSD MCP Server — .gsd/ directory resolution
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { existsSync, statSync, readdirSync } from 'node:fs';
import { join, resolve, dirname, basename } from 'node:path';
import { execFileSync } from 'node:child_process';
/**
* Resolve the .gsd/ root directory for a project.
*
* Probes in order:
* 1. projectDir/.gsd (fast path)
* 2. git repo root/.gsd
* 3. Walk up from projectDir
* 4. Fallback: projectDir/.gsd (even if missing for init)
*/
export function resolveGsdRoot(projectDir: string): string {
const resolved = resolve(projectDir);
// Fast path: .gsd/ in the given directory
const direct = join(resolved, '.gsd');
if (existsSync(direct) && statSync(direct).isDirectory()) {
return direct;
}
// Try git repo root
try {
const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd: resolved,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
const gitGsd = join(gitRoot, '.gsd');
if (existsSync(gitGsd) && statSync(gitGsd).isDirectory()) {
return gitGsd;
}
} catch {
// Not a git repo or git not available
}
// Walk up from projectDir
let dir = resolved;
while (dir !== dirname(dir)) {
const candidate = join(dir, '.gsd');
if (existsSync(candidate) && statSync(candidate).isDirectory()) {
return candidate;
}
dir = dirname(dir);
}
// Fallback
return direct;
}
/** Resolve path to a .gsd/ root file (STATE.md, KNOWLEDGE.md, etc.) */
export function resolveRootFile(gsdRoot: string, name: string): string {
return join(gsdRoot, name);
}
/** Resolve path to milestones directory */
export function milestonesDir(gsdRoot: string): string {
return join(gsdRoot, 'milestones');
}
/**
* Find all milestone directory IDs (M001, M002, etc.).
* Handles both bare (M001/) and descriptor (M001-FLIGHT-SIM/) naming.
*/
export function findMilestoneIds(gsdRoot: string): string[] {
const dir = milestonesDir(gsdRoot);
if (!existsSync(dir)) return [];
const entries = readdirSync(dir, { withFileTypes: true });
const ids: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = entry.name.match(/^(M\d+)/);
if (match) ids.push(match[1]);
}
return ids.sort();
}
/**
* Resolve the actual directory name for a milestone ID.
* M001 might live in M001/ or M001-SOME-DESCRIPTOR/.
*/
export function resolveMilestoneDir(gsdRoot: string, milestoneId: string): string | null {
const dir = milestonesDir(gsdRoot);
if (!existsSync(dir)) return null;
// Fast path: exact match
const exact = join(dir, milestoneId);
if (existsSync(exact) && statSync(exact).isDirectory()) return exact;
// Prefix match
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith(milestoneId)) {
return join(dir, entry.name);
}
}
return null;
}
/**
* Resolve a milestone-level file (M001-ROADMAP.md, M001-CONTEXT.md, etc.).
* Handles various naming conventions.
*/
export function resolveMilestoneFile(gsdRoot: string, milestoneId: string, suffix: string): string | null {
const mDir = resolveMilestoneDir(gsdRoot, milestoneId);
if (!mDir) return null;
const dirName = basename(mDir);
// Try: M001-ROADMAP.md, then DIRNAME-ROADMAP.md
const candidates = [
join(mDir, `${milestoneId}-${suffix}.md`),
join(mDir, `${dirName}-${suffix}.md`),
join(mDir, `${suffix}.md`),
];
for (const c of candidates) {
if (existsSync(c)) return c;
}
return null;
}
/** Find all slice IDs within a milestone (S01, S02, etc.) */
export function findSliceIds(gsdRoot: string, milestoneId: string): string[] {
const mDir = resolveMilestoneDir(gsdRoot, milestoneId);
if (!mDir) return [];
const slicesDir = join(mDir, 'slices');
if (!existsSync(slicesDir)) return [];
const entries = readdirSync(slicesDir, { withFileTypes: true });
const ids: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = entry.name.match(/^(S\d+)/);
if (match) ids.push(match[1]);
}
return ids.sort();
}
/** Resolve the actual directory for a slice */
export function resolveSliceDir(gsdRoot: string, milestoneId: string, sliceId: string): string | null {
const mDir = resolveMilestoneDir(gsdRoot, milestoneId);
if (!mDir) return null;
const slicesDir = join(mDir, 'slices');
if (!existsSync(slicesDir)) return null;
const exact = join(slicesDir, sliceId);
if (existsSync(exact) && statSync(exact).isDirectory()) return exact;
const entries = readdirSync(slicesDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith(sliceId)) {
return join(slicesDir, entry.name);
}
}
return null;
}
/** Resolve a slice-level file (S01-PLAN.md, etc.) */
export function resolveSliceFile(
gsdRoot: string, milestoneId: string, sliceId: string, suffix: string,
): string | null {
const sDir = resolveSliceDir(gsdRoot, milestoneId, sliceId);
if (!sDir) return null;
const dirName = basename(sDir);
const candidates = [
join(sDir, `${sliceId}-${suffix}.md`),
join(sDir, `${dirName}-${suffix}.md`),
join(sDir, `${suffix}.md`),
];
for (const c of candidates) {
if (existsSync(c)) return c;
}
return null;
}
/** Find all task files in a slice's tasks/ directory */
export function findTaskFiles(
gsdRoot: string, milestoneId: string, sliceId: string,
): Array<{ id: string; hasPlan: boolean; hasSummary: boolean }> {
const sDir = resolveSliceDir(gsdRoot, milestoneId, sliceId);
if (!sDir) return [];
const tasksDir = join(sDir, 'tasks');
if (!existsSync(tasksDir)) return [];
const files = readdirSync(tasksDir);
const taskMap = new Map<string, { hasPlan: boolean; hasSummary: boolean }>();
for (const f of files) {
const match = f.match(/^(T\d+).*-(PLAN|SUMMARY)\.md$/i);
if (!match) continue;
const [, id, type] = match;
const existing = taskMap.get(id) ?? { hasPlan: false, hasSummary: false };
if (type.toUpperCase() === 'PLAN') existing.hasPlan = true;
if (type.toUpperCase() === 'SUMMARY') existing.hasSummary = true;
taskMap.set(id, existing);
}
return Array.from(taskMap.entries())
.map(([id, info]) => ({ id, ...info }))
.sort((a, b) => a.id.localeCompare(b.id));
}

View file

@ -0,0 +1,509 @@
// GSD MCP Server — reader tests
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';
import { readProgress } from './state.js';
import { readRoadmap } from './roadmap.js';
import { readHistory } from './metrics.js';
import { readCaptures } from './captures.js';
import { readKnowledge } from './knowledge.js';
import { runDoctorLite } from './doctor-lite.js';
// ---------------------------------------------------------------------------
// Test fixture helpers
// ---------------------------------------------------------------------------
function tmpProject(): string {
const dir = join(tmpdir(), `gsd-mcp-test-${randomBytes(4).toString('hex')}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function writeFixture(base: string, relPath: string, content: string): void {
const full = join(base, relPath);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, content, 'utf-8');
}
// ---------------------------------------------------------------------------
// readProgress tests
// ---------------------------------------------------------------------------
describe('readProgress', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
writeFixture(projectDir, '.gsd/STATE.md', `# GSD State
**Active Milestone:** M002: Auth System
**Active Slice:** S01: Login flow
**Phase:** execution
**Requirements Status:** 5 active · 2 validated · 1 deferred · 0 out of scope
## Milestone Registry
- **M001:** Core Setup
- 🔄 **M002:** Auth System
- **M003:** Dashboard
## Blockers
- Waiting on OAuth provider approval
## Next Action
Execute T02 in S01 implement token refresh.
`);
// Create filesystem structure
const m1 = '.gsd/milestones/M001/slices/S01/tasks';
writeFixture(projectDir, `${m1}/T01-PLAN.md`, '# T01');
writeFixture(projectDir, `${m1}/T01-SUMMARY.md`, '# T01 done');
const m2 = '.gsd/milestones/M002/slices/S01/tasks';
writeFixture(projectDir, `${m2}/T01-PLAN.md`, '# T01');
writeFixture(projectDir, `${m2}/T01-SUMMARY.md`, '# T01 done');
writeFixture(projectDir, `${m2}/T02-PLAN.md`, '# T02');
mkdirSync(join(projectDir, '.gsd/milestones/M003'), { recursive: true });
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('parses active milestone from STATE.md', () => {
const result = readProgress(projectDir);
assert.deepEqual(result.activeMilestone, { id: 'M002', title: 'Auth System' });
});
it('parses active slice', () => {
const result = readProgress(projectDir);
assert.deepEqual(result.activeSlice, { id: 'S01', title: 'Login flow' });
});
it('parses phase', () => {
const result = readProgress(projectDir);
assert.equal(result.phase, 'execute');
});
it('parses milestone counts from registry', () => {
const result = readProgress(projectDir);
assert.equal(result.milestones.total, 3);
assert.equal(result.milestones.done, 1);
assert.equal(result.milestones.active, 1);
assert.equal(result.milestones.pending, 1);
});
it('counts tasks from filesystem', () => {
const result = readProgress(projectDir);
assert.equal(result.tasks.total, 3);
assert.equal(result.tasks.done, 2);
assert.equal(result.tasks.pending, 1);
});
it('parses blockers', () => {
const result = readProgress(projectDir);
assert.equal(result.blockers.length, 1);
assert.ok(result.blockers[0].includes('OAuth'));
});
it('parses requirements', () => {
const result = readProgress(projectDir);
assert.equal(result.requirements?.active, 5);
assert.equal(result.requirements?.validated, 2);
assert.equal(result.requirements?.deferred, 1);
});
it('parses next action', () => {
const result = readProgress(projectDir);
assert.ok(result.nextAction.includes('T02'));
});
it('returns defaults for missing .gsd/', () => {
const empty = tmpProject();
const result = readProgress(empty);
assert.equal(result.phase, 'unknown');
assert.equal(result.milestones.total, 0);
rmSync(empty, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// readRoadmap tests
// ---------------------------------------------------------------------------
describe('readRoadmap', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
writeFixture(projectDir, '.gsd/milestones/M001/M001-CONTEXT.md', '# M001: Core Setup\n');
writeFixture(projectDir, '.gsd/milestones/M001/M001-ROADMAP.md', `# M001: Core Setup
## Vision
Build the foundation for the project.
## Slice Overview
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Database schema | low | | | DB ready |
| S02 | API endpoints | medium | S01 | 🟫 | REST API live |
`);
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/S01-PLAN.md', `# S01: Database schema
## Tasks
- [x] **T01: Create migrations** Set up schema
- [x] **T02: Seed data** Initial seed
`);
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 done');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T02-PLAN.md', '# T02');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T02-SUMMARY.md', '# T02 done');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/S02-PLAN.md', `# S02: API endpoints
## Tasks
- [ ] **T01: Auth routes** Implement auth
- [ ] **T02: User routes** CRUD users
`);
writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/tasks/T01-PLAN.md', '# T01');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S02/tasks/T02-PLAN.md', '# T02');
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns milestone structure', () => {
const result = readRoadmap(projectDir);
assert.equal(result.milestones.length, 1);
assert.equal(result.milestones[0].id, 'M001');
assert.equal(result.milestones[0].title, 'Core Setup');
});
it('reads vision from roadmap', () => {
const result = readRoadmap(projectDir);
assert.ok(result.milestones[0].vision.includes('foundation'));
});
it('parses slices from roadmap table', () => {
const result = readRoadmap(projectDir);
const slices = result.milestones[0].slices;
assert.equal(slices.length, 2);
assert.equal(slices[0].id, 'S01');
assert.equal(slices[0].title, 'Database schema');
assert.equal(slices[1].id, 'S02');
});
it('derives slice status from task summaries', () => {
const result = readRoadmap(projectDir);
const slices = result.milestones[0].slices;
assert.equal(slices[0].status, 'done');
assert.equal(slices[1].status, 'pending');
});
it('includes tasks in slices', () => {
const result = readRoadmap(projectDir);
const s01Tasks = result.milestones[0].slices[0].tasks;
assert.equal(s01Tasks.length, 2);
assert.equal(s01Tasks[0].status, 'done');
});
it('filters by milestoneId', () => {
const result = readRoadmap(projectDir, 'M999');
assert.equal(result.milestones.length, 0);
});
});
// ---------------------------------------------------------------------------
// readHistory tests
// ---------------------------------------------------------------------------
describe('readHistory', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
writeFixture(projectDir, '.gsd/metrics.json', JSON.stringify({
version: 1,
projectStartedAt: 1700000000000,
units: [
{
type: 'execute-task',
id: 'M001/S01/T01',
model: 'claude-sonnet-4',
startedAt: 1700001000000,
finishedAt: 1700002000000,
tokens: { input: 10000, output: 3000, cacheRead: 2000, cacheWrite: 1000, total: 16000 },
cost: 0.05,
toolCalls: 8,
apiRequests: 3,
},
{
type: 'execute-task',
id: 'M001/S01/T02',
model: 'claude-sonnet-4',
startedAt: 1700003000000,
finishedAt: 1700004000000,
tokens: { input: 15000, output: 5000, cacheRead: 3000, cacheWrite: 1500, total: 24500 },
cost: 0.08,
toolCalls: 12,
apiRequests: 5,
},
],
}));
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('returns all entries sorted by most recent', () => {
const result = readHistory(projectDir);
assert.equal(result.entries.length, 2);
assert.equal(result.entries[0].id, 'M001/S01/T02'); // most recent first
});
it('computes totals', () => {
const result = readHistory(projectDir);
assert.equal(result.totals.units, 2);
assert.equal(result.totals.cost, 0.13);
assert.equal(result.totals.tokens.total, 40500);
});
it('respects limit', () => {
const result = readHistory(projectDir, 1);
assert.equal(result.entries.length, 1);
assert.equal(result.totals.units, 2); // totals still reflect all
});
it('returns empty for missing metrics', () => {
const empty = tmpProject();
mkdirSync(join(empty, '.gsd'), { recursive: true });
const result = readHistory(empty);
assert.equal(result.entries.length, 0);
assert.equal(result.totals.units, 0);
rmSync(empty, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// readCaptures tests
// ---------------------------------------------------------------------------
describe('readCaptures', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
writeFixture(projectDir, '.gsd/CAPTURES.md', `# Captures
### CAP-aaa11111
**Text:** Add rate limiting to API
**Captured:** 2026-04-01T10:00:00Z
**Status:** pending
### CAP-bbb22222
**Text:** Refactor auth module
**Captured:** 2026-04-02T10:00:00Z
**Status:** resolved
**Classification:** inject
**Resolution:** Added to M003 roadmap
**Rationale:** Important for security
**Resolved:** 2026-04-03T10:00:00Z
**Milestone:** M003
### CAP-ccc33333
**Text:** Nice to have: dark mode
**Captured:** 2026-04-02T11:00:00Z
**Status:** resolved
**Classification:** defer
**Resolution:** Deferred to future
**Rationale:** Not blocking
**Resolved:** 2026-04-03T11:00:00Z
`);
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('reads all captures', () => {
const result = readCaptures(projectDir, 'all');
assert.equal(result.captures.length, 3);
assert.equal(result.counts.total, 3);
});
it('filters pending captures', () => {
const result = readCaptures(projectDir, 'pending');
assert.equal(result.captures.length, 1);
assert.equal(result.captures[0].id, 'CAP-aaa11111');
});
it('filters actionable captures (inject, replan, quick-task)', () => {
const result = readCaptures(projectDir, 'actionable');
assert.equal(result.captures.length, 1);
assert.equal(result.captures[0].id, 'CAP-bbb22222');
});
it('counts correctly regardless of filter', () => {
const result = readCaptures(projectDir, 'pending');
assert.equal(result.counts.total, 3);
assert.equal(result.counts.pending, 1);
assert.equal(result.counts.actionable, 1);
});
it('returns empty for missing CAPTURES.md', () => {
const empty = tmpProject();
mkdirSync(join(empty, '.gsd'), { recursive: true });
const result = readCaptures(empty);
assert.equal(result.captures.length, 0);
rmSync(empty, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// readKnowledge tests
// ---------------------------------------------------------------------------
describe('readKnowledge', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
writeFixture(projectDir, '.gsd/KNOWLEDGE.md', `# Project Knowledge
## Rules
| # | Scope | Rule | Why | Added |
|---|-------|------|-----|-------|
| K001 | auth | Hash passwords with bcrypt | Security requirement | manual |
| K002 | db | Use transactions for multi-table | Data consistency | auto |
## Patterns
| # | Pattern | Where | Notes |
|---|---------|-------|-------|
| P001 | Singleton services | services/ | Prevents duplication |
## Lessons Learned
| # | What Happened | Root Cause | Fix | Scope |
|---|--------------|------------|-----|-------|
| L001 | CI tests failed | Env diff | Added setup script | testing |
`);
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('reads all knowledge entries', () => {
const result = readKnowledge(projectDir);
assert.equal(result.entries.length, 4);
});
it('counts by type', () => {
const result = readKnowledge(projectDir);
assert.equal(result.counts.rules, 2);
assert.equal(result.counts.patterns, 1);
assert.equal(result.counts.lessons, 1);
});
it('parses rule fields correctly', () => {
const result = readKnowledge(projectDir);
const k001 = result.entries.find((e) => e.id === 'K001');
assert.ok(k001);
assert.equal(k001.type, 'rule');
assert.equal(k001.scope, 'auth');
assert.ok(k001.content.includes('bcrypt'));
});
it('returns empty for missing KNOWLEDGE.md', () => {
const empty = tmpProject();
mkdirSync(join(empty, '.gsd'), { recursive: true });
const result = readKnowledge(empty);
assert.equal(result.entries.length, 0);
rmSync(empty, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// runDoctorLite tests
// ---------------------------------------------------------------------------
describe('runDoctorLite', () => {
let projectDir: string;
before(() => {
projectDir = tmpProject();
// M001: complete milestone (has summary)
writeFixture(projectDir, '.gsd/PROJECT.md', '# Test Project');
writeFixture(projectDir, '.gsd/STATE.md', '# GSD State');
writeFixture(projectDir, '.gsd/milestones/M001/M001-CONTEXT.md', '# M001');
writeFixture(projectDir, '.gsd/milestones/M001/M001-ROADMAP.md', '# Roadmap');
writeFixture(projectDir, '.gsd/milestones/M001/M001-SUMMARY.md', '# Done');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/S01-PLAN.md', '# Plan');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md', '# T01');
writeFixture(projectDir, '.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md', '# T01 done');
// M002: incomplete — has all tasks done but no SUMMARY
writeFixture(projectDir, '.gsd/milestones/M002/M002-CONTEXT.md', '# M002');
writeFixture(projectDir, '.gsd/milestones/M002/M002-ROADMAP.md', '# Roadmap');
writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/S01-PLAN.md', '# Plan');
writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/tasks/T01-PLAN.md', '# T01');
writeFixture(projectDir, '.gsd/milestones/M002/slices/S01/tasks/T01-SUMMARY.md', '# T01 done');
// M003: empty — no context, no slices
mkdirSync(join(projectDir, '.gsd/milestones/M003'), { recursive: true });
});
after(() => rmSync(projectDir, { recursive: true, force: true }));
it('detects all-slices-done-missing-summary', () => {
const result = runDoctorLite(projectDir);
const issue = result.issues.find((i) => i.code === 'all_slices_done_missing_summary');
assert.ok(issue, 'Should detect M002 missing summary');
assert.equal(issue.unitId, 'M002');
});
it('detects missing context', () => {
const result = runDoctorLite(projectDir);
const issue = result.issues.find(
(i) => i.code === 'missing_context' && i.unitId === 'M003',
);
assert.ok(issue, 'Should detect M003 missing context');
});
it('scopes to a single milestone', () => {
const result = runDoctorLite(projectDir, 'M001');
const m002Issues = result.issues.filter((i) => i.unitId.startsWith('M002'));
assert.equal(m002Issues.length, 0, 'Should not include M002 when scoped to M001');
});
it('returns ok:true for healthy project', () => {
const healthy = tmpProject();
writeFixture(healthy, '.gsd/PROJECT.md', '# Project');
writeFixture(healthy, '.gsd/STATE.md', '# State');
const result = runDoctorLite(healthy);
assert.equal(result.ok, true);
rmSync(healthy, { recursive: true, force: true });
});
it('handles missing .gsd/ gracefully', () => {
const empty = tmpProject();
const result = runDoctorLite(empty);
assert.equal(result.ok, true);
assert.equal(result.issues[0].code, 'no_gsd_directory');
rmSync(empty, { recursive: true, force: true });
});
});

View file

@ -0,0 +1,263 @@
// GSD MCP Server — roadmap structure reader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readFileSync, existsSync } from 'node:fs';
import {
resolveGsdRoot,
findMilestoneIds,
resolveMilestoneFile,
findSliceIds,
resolveSliceFile,
findTaskFiles,
} from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TaskInfo {
id: string;
title: string;
status: 'done' | 'pending';
}
export interface SliceInfo {
id: string;
title: string;
status: 'done' | 'active' | 'pending';
risk: string;
depends: string[];
demo: string;
tasks: TaskInfo[];
}
export interface MilestoneInfo {
id: string;
title: string;
status: 'done' | 'active' | 'pending' | 'parked';
vision: string;
slices: SliceInfo[];
}
export interface RoadmapResult {
milestones: MilestoneInfo[];
}
// ---------------------------------------------------------------------------
// ROADMAP.md table parser
// ---------------------------------------------------------------------------
function parseRoadmapTable(content: string): Array<{
id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string;
}> {
const results: Array<{
id: string; title: string; risk: string; depends: string[]; done: boolean; demo: string;
}> = [];
// Try table format first: | S01 | Title | risk | depends | done-icon | demo |
const tableSection = content.match(/## (?:Slice[s]?|Slice Overview|Slice Table)\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (tableSection) {
const lines = tableSection[1].split('\n');
for (const line of lines) {
if (!line.includes('|')) continue;
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
if (cells.length < 4) continue;
if (cells[0] === 'ID' || cells[0].startsWith('--')) continue;
const id = cells[0].match(/S\d+/)?.[0];
if (!id) continue;
const done = cells.some((c) => c === '\u2611' || c === '\u2705' || c.toLowerCase() === 'done');
const depends = (cells[3] ?? '').replace(/\u2014/g, '').split(',').map((d) => d.trim()).filter(Boolean);
results.push({
id,
title: cells[1] ?? '',
risk: cells[2] ?? 'medium',
depends,
done,
demo: cells[5] ?? '',
});
}
if (results.length > 0) return results;
}
// Try checkbox format: - [x] **S01: Title** `risk:high` `depends:[S01]`
const checkboxRe = /^-\s+\[([ xX])\]\s+\*\*(S\d+):\s*(.+?)\*\*(?:.*?`risk:(\w+)`)?(?:.*?`depends:\[([^\]]*)\]`)?/gm;
let match: RegExpExecArray | null;
while ((match = checkboxRe.exec(content)) !== null) {
const [, checked, id, title, risk, deps] = match;
results.push({
id,
title: title.trim(),
risk: risk ?? 'medium',
depends: deps ? deps.split(',').map((d) => d.trim()).filter(Boolean) : [],
done: checked !== ' ',
demo: '',
});
}
if (results.length > 0) return results;
// Try prose headers: ## S01: Title
const headerRe = /^##\s+(S\d+):\s*(.+)/gm;
while ((match = headerRe.exec(content)) !== null) {
results.push({
id: match[1],
title: match[2].trim(),
risk: 'medium',
depends: [],
done: false,
demo: '',
});
}
return results;
}
// ---------------------------------------------------------------------------
// PLAN.md task parser
// ---------------------------------------------------------------------------
function parseSlicePlanTasks(content: string): Array<{ id: string; title: string; done: boolean }> {
const results: Array<{ id: string; title: string; done: boolean }> = [];
// Checkbox format: - [x] **T01: Title** — description
const taskRe = /^-\s+\[([ xX])\]\s+\*\*(T\d+):\s*(.+?)\*\*/gm;
let match: RegExpExecArray | null;
while ((match = taskRe.exec(content)) !== null) {
results.push({
id: match[2],
title: match[3].trim(),
done: match[1] !== ' ',
});
}
if (results.length > 0) return results;
// H3 format: ### T01: Title
const h3Re = /^###\s+(T\d+):\s*(.+)/gm;
while ((match = h3Re.exec(content)) !== null) {
results.push({
id: match[1],
title: match[2].trim(),
done: false,
});
}
return results;
}
// ---------------------------------------------------------------------------
// Milestone title from CONTEXT.md or ROADMAP.md H1
// ---------------------------------------------------------------------------
function readMilestoneTitle(gsdRoot: string, mid: string): string {
const ctxPath = resolveMilestoneFile(gsdRoot, mid, 'CONTEXT');
if (ctxPath && existsSync(ctxPath)) {
const content = readFileSync(ctxPath, 'utf-8');
const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m);
if (h1) return h1[1].trim();
}
const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP');
if (roadmapPath && existsSync(roadmapPath)) {
const content = readFileSync(roadmapPath, 'utf-8');
const h1 = content.match(/^#\s+(?:M\d+:?\s*)?(.+)/m);
if (h1) return h1[1].trim();
}
return mid;
}
function readVision(gsdRoot: string, mid: string): string {
const roadmapPath = resolveMilestoneFile(gsdRoot, mid, 'ROADMAP');
if (!roadmapPath || !existsSync(roadmapPath)) return '';
const content = readFileSync(roadmapPath, 'utf-8');
const section = content.match(/## Vision\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
return section ? section[1].trim() : '';
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function readRoadmap(projectDir: string, filterMilestoneId?: string): RoadmapResult {
const gsd = resolveGsdRoot(projectDir);
let milestoneIds = findMilestoneIds(gsd);
if (filterMilestoneId) {
milestoneIds = milestoneIds.filter((id) => id === filterMilestoneId);
}
const milestones: MilestoneInfo[] = [];
for (const mid of milestoneIds) {
const title = readMilestoneTitle(gsd, mid);
const vision = readVision(gsd, mid);
const summaryPath = resolveMilestoneFile(gsd, mid, 'SUMMARY');
const hasSummary = summaryPath !== null && existsSync(summaryPath);
const roadmapPath = resolveMilestoneFile(gsd, mid, 'ROADMAP');
let roadmapSlices: ReturnType<typeof parseRoadmapTable> = [];
if (roadmapPath && existsSync(roadmapPath)) {
roadmapSlices = parseRoadmapTable(readFileSync(roadmapPath, 'utf-8'));
}
const fsSliceIds = findSliceIds(gsd, mid);
const sliceIdSet = new Set([
...roadmapSlices.map((s) => s.id),
...fsSliceIds,
]);
const slices: SliceInfo[] = [];
for (const sid of Array.from(sliceIdSet).sort()) {
const roadmapEntry = roadmapSlices.find((s) => s.id === sid);
const taskFiles = findTaskFiles(gsd, mid, sid);
const planPath = resolveSliceFile(gsd, mid, sid, 'PLAN');
let planTasks: ReturnType<typeof parseSlicePlanTasks> = [];
if (planPath && existsSync(planPath)) {
planTasks = parseSlicePlanTasks(readFileSync(planPath, 'utf-8'));
}
const tasks: TaskInfo[] = [];
const seenIds = new Set<string>();
for (const pt of planTasks) {
const fsTask = taskFiles.find((t) => t.id === pt.id);
const done = fsTask?.hasSummary ?? pt.done;
tasks.push({ id: pt.id, title: pt.title, status: done ? 'done' : 'pending' });
seenIds.add(pt.id);
}
for (const ft of taskFiles) {
if (seenIds.has(ft.id)) continue;
tasks.push({ id: ft.id, title: ft.id, status: ft.hasSummary ? 'done' : 'pending' });
}
const allDone = tasks.length > 0 && tasks.every((t) => t.status === 'done');
const anyDone = tasks.some((t) => t.status === 'done');
const sliceStatus: SliceInfo['status'] = allDone ? 'done' : anyDone ? 'active' : 'pending';
slices.push({
id: sid,
title: roadmapEntry?.title ?? sid,
status: sliceStatus,
risk: roadmapEntry?.risk ?? 'medium',
depends: roadmapEntry?.depends ?? [],
demo: roadmapEntry?.demo ?? '',
tasks,
});
}
const allSlicesDone = slices.length > 0 && slices.every((s) => s.status === 'done');
const anySliceActive = slices.some((s) => s.status === 'active' || s.status === 'done');
const milestoneStatus: MilestoneInfo['status'] = hasSummary
? 'done'
: allSlicesDone ? 'done' : anySliceActive ? 'active' : 'pending';
milestones.push({ id: mid, title, status: milestoneStatus, vision, slices });
}
return { milestones };
}

View file

@ -0,0 +1,223 @@
// GSD MCP Server — project state reader
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
import { readFileSync, existsSync } from 'node:fs';
import {
resolveGsdRoot,
resolveRootFile,
findMilestoneIds,
resolveMilestoneDir,
resolveMilestoneFile,
findSliceIds,
findTaskFiles,
} from './paths.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProgressResult {
activeMilestone: { id: string; title: string } | null;
activeSlice: { id: string; title: string } | null;
activeTask: { id: string; title: string } | null;
phase: string;
milestones: { total: number; done: number; active: number; pending: number; parked: number };
slices: { total: number; done: number; active: number; pending: number };
tasks: { total: number; done: number; pending: number };
requirements: { active: number; validated: number; deferred: number; outOfScope: number } | null;
blockers: string[];
nextAction: string;
}
// ---------------------------------------------------------------------------
// STATE.md parser
// ---------------------------------------------------------------------------
function parseBoldField(content: string, label: string): string | null {
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`, 'i');
const m = content.match(re);
return m ? m[1].trim() : null;
}
function parseActiveRef(value: string | null): { id: string; title: string } | null {
if (!value || value.toLowerCase() === 'none' || value === '—') return null;
// "M001: Flight Simulator" or "M001"
const m = value.match(/^(M\d+|S\d+|T\d+):?\s*(.*)/);
if (m) return { id: m[1], title: m[2] || m[1] };
return { id: value, title: value };
}
function parsePhase(value: string | null): string {
if (!value) return 'unknown';
const lower = value.toLowerCase().trim();
if (lower.includes('research') || lower.includes('discuss')) return 'research';
if (lower.includes('plan')) return 'plan';
if (lower.includes('execut')) return 'execute';
if (lower.includes('complete') || lower.includes('done')) return 'complete';
return lower;
}
function parseRequirementsLine(value: string | null): ProgressResult['requirements'] | null {
if (!value) return null;
const active = value.match(/(\d+)\s*active/i);
const validated = value.match(/(\d+)\s*validated/i);
const deferred = value.match(/(\d+)\s*deferred/i);
const outOfScope = value.match(/(\d+)\s*out.of.scope/i);
if (!active && !validated && !deferred && !outOfScope) return null;
return {
active: active ? parseInt(active[1], 10) : 0,
validated: validated ? parseInt(validated[1], 10) : 0,
deferred: deferred ? parseInt(deferred[1], 10) : 0,
outOfScope: outOfScope ? parseInt(outOfScope[1], 10) : 0,
};
}
function parseBlockers(content: string): string[] {
const section = content.match(/## Blockers\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (!section) return [];
return section[1]
.split('\n')
.map((l) => l.replace(/^[-*]\s*/, '').trim())
.filter(Boolean);
}
function parseNextAction(content: string): string {
const section = content.match(/## Next Action\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (!section) return '';
return section[1].trim().split('\n')[0] || '';
}
// ---------------------------------------------------------------------------
// Milestone registry from STATE.md
// ---------------------------------------------------------------------------
interface RegistryEntry { id: string; status: 'done' | 'active' | 'pending' | 'parked' }
function parseMilestoneRegistry(content: string): RegistryEntry[] {
const section = content.match(/## Milestone Registry\s*\n([\s\S]*?)(?=\n##|\n$|$)/i);
if (!section) return [];
const entries: RegistryEntry[] = [];
for (const line of section[1].split('\n')) {
const m = line.match(/[-*]\s*(☑|✅|🔄|⬜|⏸)\s*\*\*(M\d+):\*\*/);
if (!m) continue;
const [, icon, id] = m;
let status: RegistryEntry['status'] = 'pending';
if (icon === '☑' || icon === '✅') status = 'done';
else if (icon === '🔄') status = 'active';
else if (icon === '⏸') status = 'parked';
entries.push({ id, status });
}
return entries;
}
// ---------------------------------------------------------------------------
// Count slices/tasks by walking filesystem
// ---------------------------------------------------------------------------
function countSlicesAndTasks(gsdRoot: string, milestoneIds: string[]): {
slices: ProgressResult['slices'];
tasks: ProgressResult['tasks'];
} {
let sliceTotal = 0, sliceDone = 0, sliceActive = 0;
let taskTotal = 0, taskDone = 0;
for (const mid of milestoneIds) {
const sliceIds = findSliceIds(gsdRoot, mid);
sliceTotal += sliceIds.length;
for (const sid of sliceIds) {
const tasks = findTaskFiles(gsdRoot, mid, sid);
taskTotal += tasks.length;
const allDone = tasks.length > 0 && tasks.every((t) => t.hasSummary);
const anyDone = tasks.some((t) => t.hasSummary);
if (allDone) {
sliceDone++;
taskDone += tasks.length;
} else {
if (anyDone) sliceActive++;
taskDone += tasks.filter((t) => t.hasSummary).length;
}
}
}
return {
slices: {
total: sliceTotal,
done: sliceDone,
active: sliceActive,
pending: sliceTotal - sliceDone - sliceActive,
},
tasks: { total: taskTotal, done: taskDone, pending: taskTotal - taskDone },
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function readProgress(projectDir: string): ProgressResult {
const gsd = resolveGsdRoot(projectDir);
const statePath = resolveRootFile(gsd, 'STATE.md');
// Defaults
const result: ProgressResult = {
activeMilestone: null,
activeSlice: null,
activeTask: null,
phase: 'unknown',
milestones: { total: 0, done: 0, active: 0, pending: 0, parked: 0 },
slices: { total: 0, done: 0, active: 0, pending: 0 },
tasks: { total: 0, done: 0, pending: 0 },
requirements: null,
blockers: [],
nextAction: '',
};
if (!existsSync(statePath)) {
// No STATE.md — derive from filesystem only
const milestoneIds = findMilestoneIds(gsd);
result.milestones.total = milestoneIds.length;
result.milestones.pending = milestoneIds.length;
const counts = countSlicesAndTasks(gsd, milestoneIds);
result.slices = counts.slices;
result.tasks = counts.tasks;
return result;
}
const content = readFileSync(statePath, 'utf-8');
// Parse STATE.md fields
result.activeMilestone = parseActiveRef(parseBoldField(content, 'Active Milestone'));
result.activeSlice = parseActiveRef(parseBoldField(content, 'Active Slice'));
result.activeTask = parseActiveRef(parseBoldField(content, 'Active Task'));
result.phase = parsePhase(parseBoldField(content, 'Phase'));
result.requirements = parseRequirementsLine(parseBoldField(content, 'Requirements Status'));
result.blockers = parseBlockers(content);
result.nextAction = parseNextAction(content);
// Milestone counts from registry
const registry = parseMilestoneRegistry(content);
if (registry.length > 0) {
result.milestones.total = registry.length;
result.milestones.done = registry.filter((e) => e.status === 'done').length;
result.milestones.active = registry.filter((e) => e.status === 'active').length;
result.milestones.parked = registry.filter((e) => e.status === 'parked').length;
result.milestones.pending = registry.length -
result.milestones.done - result.milestones.active - result.milestones.parked;
} else {
// Fallback: count directories
const milestoneIds = findMilestoneIds(gsd);
result.milestones.total = milestoneIds.length;
result.milestones.pending = milestoneIds.length;
}
// Slice/task counts from filesystem
const milestoneIds = findMilestoneIds(gsd);
const counts = countSlicesAndTasks(gsd, milestoneIds);
result.slices = counts.slices;
result.tasks = counts.tasks;
return result;
}

View file

@ -1,5 +1,8 @@
/**
* MCP Server registers 6 GSD orchestration tools on McpServer.
* MCP Server registers GSD orchestration + read-only project state tools.
*
* Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
* Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
*
* Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
* cannot resolve the SDK's subpath exports statically (same pattern as
@ -10,6 +13,12 @@ import { readFile, readdir, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { z } from 'zod';
import type { SessionManager } from './session-manager.js';
import { readProgress } from './readers/state.js';
import { readRoadmap } from './readers/roadmap.js';
import { readHistory } from './readers/metrics.js';
import { readCaptures } from './readers/captures.js';
import { readKnowledge } from './readers/knowledge.js';
import { runDoctorLite } from './readers/doctor-lite.js';
// ---------------------------------------------------------------------------
// Constants
@ -17,7 +26,7 @@ import type { SessionManager } from './session-manager.js';
const MCP_PKG = '@modelcontextprotocol/sdk';
const SERVER_NAME = 'gsd';
const SERVER_VERSION = '2.51.0';
const SERVER_VERSION = '2.53.0';
// ---------------------------------------------------------------------------
// Tool result helpers
@ -106,7 +115,7 @@ interface McpServerInstance {
// ---------------------------------------------------------------------------
/**
* Create and configure an MCP server with 6 GSD orchestration tools.
* Create and configure an MCP server with 12 GSD tools (6 session + 6 read-only).
*
* Returns the McpServer instance call `connect(transport)` to start serving.
* Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues.
@ -274,5 +283,127 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
},
);
// =======================================================================
// READ-ONLY TOOLS — no session required, pure filesystem reads
// =======================================================================
// -----------------------------------------------------------------------
// gsd_progress — structured project progress metrics
// -----------------------------------------------------------------------
server.tool(
'gsd_progress',
'Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .gsd/ on disk.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
},
async (args: Record<string, unknown>) => {
const { projectDir } = args as { projectDir: string };
try {
return jsonContent(readProgress(projectDir));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_roadmap — milestone/slice/task structure with status
// -----------------------------------------------------------------------
server.tool(
'gsd_roadmap',
'Get the full project roadmap structure: milestones with their slices, tasks, status, risk, and dependencies. Optionally filter to a single milestone. No session required.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
milestoneId: z.string().optional().describe('Filter to a specific milestone (e.g. "M001")'),
},
async (args: Record<string, unknown>) => {
const { projectDir, milestoneId } = args as { projectDir: string; milestoneId?: string };
try {
return jsonContent(readRoadmap(projectDir, milestoneId));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_history — execution history with cost/token metrics
// -----------------------------------------------------------------------
server.tool(
'gsd_history',
'Get execution history with cost, token usage, model, and duration per unit. Returns totals across all units. No session required.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
limit: z.number().optional().describe('Max entries to return (most recent first). Default: all.'),
},
async (args: Record<string, unknown>) => {
const { projectDir, limit } = args as { projectDir: string; limit?: number };
try {
return jsonContent(readHistory(projectDir, limit));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_doctor — lightweight structural health check
// -----------------------------------------------------------------------
server.tool(
'gsd_doctor',
'Run a lightweight structural health check on the .gsd/ directory. Checks for missing files, status inconsistencies, and orphaned state. No session required.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
scope: z.string().optional().describe('Limit checks to a specific milestone (e.g. "M001")'),
},
async (args: Record<string, unknown>) => {
const { projectDir, scope } = args as { projectDir: string; scope?: string };
try {
return jsonContent(runDoctorLite(projectDir, scope));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_captures — pending captures and ideas
// -----------------------------------------------------------------------
server.tool(
'gsd_captures',
'Get captured ideas and thoughts from CAPTURES.md with triage status. Filter by pending, actionable, or all. No session required.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
filter: z.enum(['all', 'pending', 'actionable']).optional().describe('Filter captures (default: "all")'),
},
async (args: Record<string, unknown>) => {
const { projectDir, filter } = args as { projectDir: string; filter?: 'all' | 'pending' | 'actionable' };
try {
return jsonContent(readCaptures(projectDir, filter ?? 'all'));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// -----------------------------------------------------------------------
// gsd_knowledge — project knowledge base
// -----------------------------------------------------------------------
server.tool(
'gsd_knowledge',
'Get the project knowledge base: rules, patterns, and lessons learned accumulated during development. No session required.',
{
projectDir: z.string().describe('Absolute path to the project directory'),
},
async (args: Record<string, unknown>) => {
const { projectDir } = args as { projectDir: string };
try {
return jsonContent(readKnowledge(projectDir));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
return { server };
}