- All gsdDir/gsdRoot/gsdHome → sfDir/sfRootDir/sfHome - GSDWorkspace* → SFWorkspace* interfaces - bootstrapGsdProject → bootstrapProject - runGSDDoctor → runSFDoctor - GsdClient → SfClient, gsd-client.ts → sf-client.ts - .gsd/ → .sf/ in all tests, docs, docker, native, vscode - Auto-migration: headless detects .gsd/ → renames to .sf/ - Deleted gsd-phase-state.ts backward-compat re-export - Renamed bin/gsd-from-source → bin/sf-from-source - Updated mintlify docs, github workflows, docker configs
889 lines
35 KiB
TypeScript
889 lines
35 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>> {
|
|
const sfDir = join(resolve(projectDir), '.sf');
|
|
const category = normalizeQuery(query);
|
|
const wanted = new Set<ProjectStateField>(QUERY_FIELDS[category]);
|
|
|
|
const result: Record<string, unknown> = {
|
|
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<boolean> {
|
|
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<string, string | number | boolean | string[]>;
|
|
}
|
|
|
|
interface ElicitRequestFormParams {
|
|
mode?: 'form';
|
|
message: string;
|
|
requestedSchema: {
|
|
type: 'object';
|
|
properties: Record<string, Record<string, unknown>>;
|
|
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<void>;
|
|
}
|
|
|
|
interface McpServerInstance {
|
|
tool(
|
|
name: string,
|
|
description: string,
|
|
params: Record<string, unknown>,
|
|
handler: (args: Record<string, unknown>, extra?: McpToolExtra) => Promise<unknown>,
|
|
): unknown;
|
|
server: {
|
|
elicitInput(
|
|
params: AskUserQuestionsElicitRequest | ElicitRequestFormParams,
|
|
options?: unknown,
|
|
): Promise<AskUserQuestionsElicitResult>;
|
|
};
|
|
connect(transport: unknown): Promise<void>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
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<string, AskUserQuestionsContentValue>;
|
|
}
|
|
|
|
interface AskUserQuestionsElicitRequest {
|
|
mode: 'form';
|
|
message: string;
|
|
requestedSchema: {
|
|
type: 'object';
|
|
properties: Record<string, Record<string, unknown>>;
|
|
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<string, Record<string, unknown>> = {};
|
|
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<string, { answers: string[] }> = {};
|
|
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<string, unknown>, 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<string, unknown>) => {
|
|
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<string, unknown>).type === 'tool_use' ||
|
|
(e as Record<string, unknown>).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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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<string, Record<string, unknown>> = {};
|
|
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<string, unknown>) => {
|
|
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<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));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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<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));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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<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));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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<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));
|
|
}
|
|
},
|
|
);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 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<string, unknown>) => {
|
|
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<string, unknown>) => {
|
|
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 };
|
|
}
|