/** * MCP Server — registers SF orchestration, project-state, and workflow tools. * * Session tools (6): sf_execute, sf_status, sf_result, sf_cancel, sf_query, sf_resolve_blocker * Interactive tools (2): ask_user_questions, secure_env_collect via MCP form elicitation * Read-only tools (6): sf_progress, sf_roadmap, sf_history, sf_doctor, sf_captures, sf_knowledge * Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools * * Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16 * cannot resolve the SDK's subpath exports statically (same pattern as * src/mcp-server.ts in the main package). */ 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 { buildGraph, writeGraph, writeSnapshot, graphStatus, graphQuery, graphDiff } from './readers/graph.js'; import { resolveSFRoot } from './readers/paths.js'; import { runDoctorLite } from './readers/doctor-lite.js'; import { registerWorkflowTools } from './workflow-tools.js'; import { applySecrets, checkExistingEnvKeys, detectDestination } from './env-writer.js'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const MCP_PKG = '@modelcontextprotocol/sdk'; const SERVER_NAME = 'sf'; const SERVER_VERSION = '2.53.0'; // --------------------------------------------------------------------------- // Tool result helpers // --------------------------------------------------------------------------- /** Wrap a JSON-serializable value as MCP tool content. */ function jsonContent(data: unknown): { content: Array<{ type: 'text'; text: string }> } { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } /** Return an MCP error response. */ function errorContent(message: string): { isError: true; content: Array<{ type: 'text'; text: string }> } { return { isError: true, content: [{ type: 'text' as const, text: message }] }; } /** Return raw text content without JSON wrapping. */ function textContent(text: string): { content: Array<{ type: 'text'; text: string }> } { return { content: [{ type: 'text' as const, text }] }; } // --------------------------------------------------------------------------- // sf_query filesystem reader // --------------------------------------------------------------------------- /** * Normalized query categories for {@link readProjectState}. * * Maps user-supplied query strings (or empty) to the set of fields we return. * Accepts common synonyms so the MCP client can pass intuitive values. */ const QUERY_FIELDS = { all: ['state', 'project', 'requirements', 'milestones'] as const, state: ['state'] as const, status: ['state'] as const, project: ['project'] as const, requirements: ['requirements'] as const, milestones: ['milestones'] as const, } as const; type QueryCategory = keyof typeof QUERY_FIELDS; type ProjectStateField = (typeof QUERY_FIELDS)[QueryCategory][number]; function normalizeQuery(query: string | undefined): QueryCategory { const key = (query ?? 'all').trim().toLowerCase(); if (key in QUERY_FIELDS) return key as QueryCategory; return 'all'; } async function readProjectState(projectDir: string, query: string | undefined): Promise> { const sfDir = join(resolve(projectDir), '.sf'); const category = normalizeQuery(query); const wanted = new Set(QUERY_FIELDS[category]); const result: Record = { projectDir: resolve(projectDir), query: category, }; if (wanted.has('state')) { try { result.state = await readFile(join(sfDir, 'STATE.md'), 'utf-8'); } catch { result.state = null; } } if (wanted.has('project')) { try { result.project = await readFile(join(sfDir, 'PROJECT.md'), 'utf-8'); } catch { result.project = null; } } if (wanted.has('requirements')) { try { result.requirements = await readFile(join(sfDir, 'REQUIREMENTS.md'), 'utf-8'); } catch { result.requirements = null; } } if (wanted.has('milestones')) { const milestonesDir = join(sfDir, 'milestones'); try { const entries = await readdir(milestonesDir, { withFileTypes: true }); const milestones: Array<{ id: string; hasRoadmap: boolean; hasSummary: boolean }> = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const mDir = join(milestonesDir, entry.name); const hasRoadmap = await fileExists(join(mDir, `${entry.name}-ROADMAP.md`)); const hasSummary = await fileExists(join(mDir, `${entry.name}-SUMMARY.md`)); milestones.push({ id: entry.name, hasRoadmap, hasSummary }); } result.milestones = milestones; } catch { result.milestones = []; } } return result; } async function fileExists(path: string): Promise { try { await stat(path); return true; } catch { return false; } } // --------------------------------------------------------------------------- // MCP Server type — minimal interface for the dynamically-imported McpServer // --------------------------------------------------------------------------- interface ElicitResult { action: 'accept' | 'decline' | 'cancel'; content?: Record; } interface ElicitRequestFormParams { mode?: 'form'; message: string; requestedSchema: { type: 'object'; properties: Record>; required?: string[]; }; } /** * Handler extra — the second argument passed by McpServer.tool handlers. * Contains an AbortSignal scoped to the JSON-RPC request (cancelled when * the client cancels the `tools/call`) plus other per-request metadata. * Tools that can actually be stopped mid-flight should honour `signal`. */ export interface McpToolExtra { signal?: AbortSignal; requestId?: string | number; sendNotification?: (notification: unknown) => void | Promise; } interface McpServerInstance { tool( name: string, description: string, params: Record, handler: (args: Record, extra?: McpToolExtra) => Promise, ): unknown; server: { elicitInput( params: AskUserQuestionsElicitRequest | ElicitRequestFormParams, options?: unknown, ): Promise; }; connect(transport: unknown): Promise; close(): Promise; } interface AskUserQuestionOption { label: string; description: string; } interface AskUserQuestion { id: string; header: string; question: string; options: AskUserQuestionOption[]; allowMultiple?: boolean; } interface AskUserQuestionsParams { questions: AskUserQuestion[]; } type AskUserQuestionsContentValue = string | number | boolean | string[]; interface AskUserQuestionsElicitResult { action: 'accept' | 'decline' | 'cancel'; content?: Record; } interface AskUserQuestionsElicitRequest { mode: 'form'; message: string; requestedSchema: { type: 'object'; properties: Record>; required?: string[]; }; } const OTHER_OPTION_LABEL = 'None of the above'; function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string { return typeof value === 'string' ? value.trim() : ''; } function normalizeAskUserQuestionsAnswers( value: AskUserQuestionsContentValue | undefined, allowMultiple: boolean, ): string[] { if (allowMultiple) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } return typeof value === 'string' && value.length > 0 ? [value] : []; } function validateAskUserQuestionsPayload(questions: AskUserQuestion[]): string | null { if (questions.length === 0 || questions.length > 3) { return 'Error: questions must contain 1-3 items'; } for (const question of questions) { if (!question.options || question.options.length === 0) { return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`; } } return null; } export function buildAskUserQuestionsElicitRequest(questions: AskUserQuestion[]): AskUserQuestionsElicitRequest { const properties: Record> = {}; const required = questions.map((question) => question.id); for (const question of questions) { if (question.allowMultiple) { properties[question.id] = { type: 'array', title: question.header, description: question.question, minItems: 1, maxItems: question.options.length, items: { anyOf: question.options.map((option) => ({ const: option.label, title: option.label, })), }, }; continue; } properties[question.id] = { type: 'string', title: question.header, description: question.question, oneOf: [...question.options, { label: OTHER_OPTION_LABEL, description: 'Choose this when the listed options do not fit.' }].map((option) => ({ const: option.label, title: option.label, })), }; properties[`${question.id}__note`] = { type: 'string', title: `${question.header} Note`, description: `Optional note for "${OTHER_OPTION_LABEL}".`, maxLength: 500, }; } return { mode: 'form', message: 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.', requestedSchema: { type: 'object', properties, required, }, }; } export function formatAskUserQuestionsElicitResult( questions: AskUserQuestion[], result: AskUserQuestionsElicitResult, ): string { const answers: Record = {}; const content = result.content ?? {}; for (const question of questions) { const answerList = normalizeAskUserQuestionsAnswers(content[question.id], !!question.allowMultiple); if (!question.allowMultiple && answerList[0] === OTHER_OPTION_LABEL) { const note = normalizeAskUserQuestionsNote(content[`${question.id}__note`]); if (note) { answerList.push(`user_note: ${note}`); } } answers[question.id] = { answers: answerList }; } return JSON.stringify({ answers }); } // --------------------------------------------------------------------------- // createMcpServer // --------------------------------------------------------------------------- /** * Create and configure an MCP server with session, read-only, and workflow tools. * * Returns the McpServer instance — call `connect(transport)` to start serving. * Uses dynamic imports for the MCP SDK to avoid TS subpath resolution issues. */ export async function createMcpServer(sessionManager: SessionManager): Promise<{ server: McpServerInstance; }> { // Dynamic import — same workaround as src/mcp-server.ts const mcpMod = await import(`${MCP_PKG}/server/mcp.js`); const McpServer = mcpMod.McpServer; const server: McpServerInstance = new McpServer( { name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {}, elicitation: {} } }, ); // ----------------------------------------------------------------------- // sf_execute — start a new SF auto-mode session. // // If the JSON-RPC request is aborted while the session is starting (or // immediately after), we cancel the session so we don't leak a background // RpcClient process. Once the session is running the caller should use // `sf_cancel` to stop it via sessionId. // ----------------------------------------------------------------------- server.tool( 'sf_execute', 'Start a SF auto-mode session for a project directory. Returns a sessionId for tracking.', { projectDir: z.string().describe('Absolute path to the project directory'), command: z.string().optional().describe('Command to send (default: "/sf auto")'), model: z.string().optional().describe('Model ID override'), bare: z.boolean().optional().describe('Run in bare mode (skip user config)'), }, async (args: Record, extra?: McpToolExtra) => { const { projectDir, command, model, bare } = args as { projectDir: string; command?: string; model?: string; bare?: boolean; }; try { const sessionId = await sessionManager.startSession(projectDir, { command, model, bare }); // If the client aborted while startSession was running, cancel the // newly-created session rather than leaving an orphaned process. if (extra?.signal?.aborted) { await sessionManager.cancelSession(sessionId).catch(() => { /* swallow */ }); return errorContent('sf_execute aborted by client before returning'); } return jsonContent({ sessionId, status: 'started' }); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_status — poll session status // ----------------------------------------------------------------------- server.tool( 'sf_status', 'Get the current status of a SF session including progress, recent events, and pending blockers.', { sessionId: z.string().describe('Session ID returned from sf_execute'), }, async (args: Record) => { const { sessionId } = args as { sessionId: string }; try { const session = sessionManager.getSession(sessionId); if (!session) return errorContent(`Session not found: ${sessionId}`); const durationMs = Date.now() - session.startTime; const toolCallCount = session.events.filter( (e) => (e as Record).type === 'tool_use' || (e as Record).type === 'tool_execution_start' ).length; return jsonContent({ status: session.status, progress: { eventCount: session.events.length, toolCalls: toolCallCount, }, recentEvents: session.events.slice(-10), pendingBlocker: session.pendingBlocker ? { id: session.pendingBlocker.id, method: session.pendingBlocker.method, message: session.pendingBlocker.message, } : null, cost: session.cost, durationMs, }); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_result — get accumulated session result // ----------------------------------------------------------------------- server.tool( 'sf_result', 'Get the result of a SF session. Returns partial results if the session is still running.', { sessionId: z.string().describe('Session ID returned from sf_execute'), }, async (args: Record) => { const { sessionId } = args as { sessionId: string }; try { const result = sessionManager.getResult(sessionId); return jsonContent(result); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_cancel — cancel a running session // ----------------------------------------------------------------------- server.tool( 'sf_cancel', 'Cancel a running SF session. Aborts the current operation and stops the process.', { sessionId: z.string().describe('Session ID returned from sf_execute'), }, async (args: Record) => { const { sessionId } = args as { sessionId: string }; try { await sessionManager.cancelSession(sessionId); return jsonContent({ cancelled: true }); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_query — read project state from filesystem (no session needed). // // `query` is optional: when omitted the tool returns all fields (STATE.md, // PROJECT.md, requirements, milestone listing). Accepted narrow values: // "state" / "status", "project", "requirements", "milestones", "all". // Unknown values fall back to "all" for forward-compatibility. // ----------------------------------------------------------------------- server.tool( 'sf_query', 'Query SF project state from the filesystem. By default returns STATE.md, PROJECT.md, requirements, and milestone listing. Pass `query` to narrow the response (accepted: "state"/"status", "project", "requirements", "milestones", "all"). Does not require an active session.', { projectDir: z.string().describe('Absolute path to the project directory'), query: z .enum(['all', 'state', 'status', 'project', 'requirements', 'milestones']) .optional() .describe('Narrow the response to a single field (default: "all")'), }, async (args: Record) => { const { projectDir, query } = args as { projectDir: string; query?: string }; try { const state = await readProjectState(projectDir, query); return jsonContent(state); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_resolve_blocker — resolve a pending blocker // ----------------------------------------------------------------------- server.tool( 'sf_resolve_blocker', 'Resolve a pending blocker in a SF session by sending a response to the UI request.', { sessionId: z.string().describe('Session ID returned from sf_execute'), response: z.string().describe('Response to send for the pending blocker'), }, async (args: Record) => { const { sessionId, response } = args as { sessionId: string; response: string }; try { await sessionManager.resolveBlocker(sessionId, response); return jsonContent({ resolved: true }); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // ask_user_questions — structured user input via MCP form elicitation // ----------------------------------------------------------------------- server.tool( 'ask_user_questions', 'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.', { questions: z.array(z.object({ id: z.string().describe('Stable identifier for mapping answers (snake_case)'), header: z.string().describe('Short header label shown in the UI (12 or fewer chars)'), question: z.string().describe('Single-sentence prompt shown to the user'), options: z.array(z.object({ label: z.string().describe('User-facing label (1-5 words)'), description: z.string().describe('One short sentence explaining impact/tradeoff if selected'), })).describe('Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.'), allowMultiple: z.boolean().optional().describe('If true, the user can select multiple options. No "None of the above" option is added.'), })).describe('Questions to show the user. Prefer 1 and do not exceed 3.'), }, async (args: Record) => { const { questions } = args as unknown as AskUserQuestionsParams; try { const validationError = validateAskUserQuestionsPayload(questions); if (validationError) return errorContent(validationError); const elicitation = await server.server.elicitInput(buildAskUserQuestionsElicitRequest(questions)); if (elicitation.action !== 'accept' || !elicitation.content) { return textContent('ask_user_questions was cancelled before receiving a response'); } return textContent(formatAskUserQuestionsElicitResult(questions, elicitation)); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // secure_env_collect — collect secrets via MCP form elicitation // ----------------------------------------------------------------------- server.tool( 'secure_env_collect', 'Collect environment variables securely via form input. Values are written directly to .env (or Vercel/Convex) and NEVER appear in tool output — only key names and applied/skipped status are returned. Use this instead of asking users to manually edit .env files or paste secrets into chat.', { projectDir: z.string().describe('Absolute path to the project directory'), keys: z.array(z.object({ key: z.string().describe('Env var name, e.g. OPENAI_API_KEY'), hint: z.string().optional().describe('Format hint shown to user, e.g. "starts with sk-"'), guidance: z.array(z.string()).optional().describe('Step-by-step instructions for obtaining this key'), })).min(1).describe('Environment variables to collect'), destination: z.enum(['dotenv', 'vercel', 'convex']).optional().describe('Where to write secrets. Auto-detected from project files if omitted.'), envFilePath: z.string().optional().describe('Path to .env file (dotenv only). Defaults to .env in projectDir.'), environment: z.enum(['development', 'preview', 'production']).optional().describe('Target environment (vercel/convex only)'), }, async (args: Record) => { const { projectDir, keys, destination, envFilePath, environment } = args as { projectDir: string; keys: Array<{ key: string; hint?: string; guidance?: string[] }>; destination?: 'dotenv' | 'vercel' | 'convex'; envFilePath?: string; environment?: 'development' | 'preview' | 'production'; }; try { const resolvedProjectDir = resolve(projectDir); const resolvedEnvPath = resolve(resolvedProjectDir, envFilePath ?? '.env'); // (1) Check which keys already exist const allKeyNames = keys.map((k) => k.key); const existingKeys = await checkExistingEnvKeys(allKeyNames, resolvedEnvPath); const existingSet = new Set(existingKeys); const pendingKeys = keys.filter((k) => !existingSet.has(k.key)); // If all keys already exist, return immediately if (pendingKeys.length === 0) { const lines = existingKeys.map((k) => `• ${k}: already set`); return textContent(`All ${existingKeys.length} key(s) already set.\n${lines.join('\n')}`); } // (2) Build elicitation form — one string field per pending key const properties: Record> = {}; const required: string[] = []; for (const item of pendingKeys) { const descParts: string[] = []; if (item.hint) descParts.push(`Format: ${item.hint}`); if (item.guidance && item.guidance.length > 0) { descParts.push('How to get this:'); item.guidance.forEach((step, i) => descParts.push(`${i + 1}. ${step}`)); } descParts.push('Leave empty to skip.'); properties[item.key] = { type: 'string', title: item.key, description: descParts.join('\n'), }; // Don't mark as required — empty string = skip } // (3) Elicit input from the MCP client const elicitation = await server.server.elicitInput({ message: `Enter values for ${pendingKeys.length} environment variable(s). Values are written directly to the project and never shown to the AI.`, requestedSchema: { type: 'object', properties, required, }, }); if (elicitation.action !== 'accept' || !elicitation.content) { return textContent('secure_env_collect was cancelled by user.'); } // (4) Separate provided vs skipped from form response const provided: Array<{ key: string; value: string }> = []; const skipped: string[] = []; for (const item of pendingKeys) { const raw = elicitation.content[item.key]; const value = typeof raw === 'string' ? raw.trim() : ''; if (value.length > 0) { provided.push({ key: item.key, value }); } else { skipped.push(item.key); } } // (5) Auto-detect destination if not specified const resolvedDestination = destination ?? detectDestination(resolvedProjectDir); // (6) Write secrets to destination const { applied, errors } = await applySecrets(provided, resolvedDestination, { envFilePath: resolvedEnvPath, environment, }); // (7) Build result — NEVER include secret values const lines: string[] = [ `destination: ${resolvedDestination}${!destination ? ' (auto-detected)' : ''}${environment ? ` (${environment})` : ''}`, ]; for (const k of applied) lines.push(`✓ ${k}: applied`); for (const k of skipped) lines.push(`• ${k}: skipped`); for (const k of existingKeys) lines.push(`• ${k}: already set`); for (const e of errors) lines.push(`✗ ${e}`); return errors.length > 0 && applied.length === 0 ? errorContent(lines.join('\n')) : textContent(lines.join('\n')); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ======================================================================= // READ-ONLY TOOLS — no session required, pure filesystem reads // ======================================================================= // ----------------------------------------------------------------------- // sf_progress — structured project progress metrics // ----------------------------------------------------------------------- server.tool( 'sf_progress', 'Get structured project progress: active milestone/slice/task, phase, completion counts, blockers, and next action. No session required — reads directly from .sf/ on disk.', { projectDir: z.string().describe('Absolute path to the project directory'), }, async (args: Record) => { const { projectDir } = args as { projectDir: string }; try { return jsonContent(readProgress(projectDir)); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_roadmap — milestone/slice/task structure with status // ----------------------------------------------------------------------- server.tool( 'sf_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) => { 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)); } }, ); // ----------------------------------------------------------------------- // sf_history — execution history with cost/token metrics // ----------------------------------------------------------------------- server.tool( 'sf_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) => { 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)); } }, ); // ----------------------------------------------------------------------- // sf_doctor — lightweight structural health check // ----------------------------------------------------------------------- server.tool( 'sf_doctor', 'Run a lightweight structural health check on the .sf/ 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) => { 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)); } }, ); // ----------------------------------------------------------------------- // sf_captures — pending captures and ideas // ----------------------------------------------------------------------- server.tool( 'sf_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) => { 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)); } }, ); // ----------------------------------------------------------------------- // sf_knowledge — project knowledge base // ----------------------------------------------------------------------- server.tool( 'sf_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) => { const { projectDir } = args as { projectDir: string }; try { return jsonContent(readKnowledge(projectDir)); } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); // ----------------------------------------------------------------------- // sf_graph — knowledge graph for SF projects // // Modes: // build Parse .sf/ artifacts and write graph.json atomically. // query Search the graph for nodes matching a term (BFS, budget-trimmed). // status Check whether graph.json exists and whether it is stale (>24h). // diff Compare graph.json with the last build snapshot. // ----------------------------------------------------------------------- server.tool( 'sf_graph', [ 'Manage the SF project knowledge graph. No session required.', '', 'Modes:', ' build Parse .sf/ artifacts (STATE.md, milestone ROADMAPs, slice PLANs,', ' KNOWLEDGE.md) and write .sf/graphs/graph.json atomically.', ' query Search graph nodes by term (BFS from seed matches, budget-trimmed).', ' Returns matching nodes and reachable edges within the token budget.', ' status Show whether graph.json exists, its age, node/edge counts, and', ' whether it is stale (built more than 24 hours ago).', ' diff Compare current graph.json with .last-build-snapshot.json.', ' Returns added, removed, and changed nodes and edges.', ].join('\n'), { projectDir: z.string().describe('Absolute path to the project directory'), mode: z.enum(['build', 'query', 'status', 'diff']).describe( 'Operation: build | query | status | diff', ), term: z.string().optional().describe('Search term for query mode (case-insensitive)'), budget: z.number().optional().describe('Token budget for query mode (default: 4000)'), snapshot: z.boolean().optional().describe('Write snapshot before build (for future diff)'), }, async (args: Record) => { const { projectDir, mode, term, budget, snapshot } = args as { projectDir: string; mode: 'build' | 'query' | 'status' | 'diff'; term?: string; budget?: number; snapshot?: boolean; }; try { const sfRoot = resolveSFRoot(projectDir); switch (mode) { case 'build': { if (snapshot) { await writeSnapshot(sfRoot).catch(() => { /* best-effort */ }); } const graph = await buildGraph(projectDir); await writeGraph(sfRoot, graph); return jsonContent({ built: true, nodeCount: graph.nodes.length, edgeCount: graph.edges.length, builtAt: graph.builtAt, }); } case 'query': { const result = await graphQuery(projectDir, term ?? '', budget); return jsonContent(result); } case 'status': { const result = await graphStatus(projectDir); return jsonContent(result); } case 'diff': { const result = await graphDiff(projectDir); return jsonContent(result); } } } catch (err) { return errorContent(err instanceof Error ? err.message : String(err)); } }, ); registerWorkflowTools(server); return { server }; }