diff --git a/README.md b/README.md index 7b6c7354..82b07625 100644 --- a/README.md +++ b/README.md @@ -253,8 +253,107 @@ openspec view # Interactive dashboard of specs and changes openspec show # Display change details (proposal, tasks, spec updates) openspec validate # Check spec formatting and structure openspec archive [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes) +openspec mcp [--debug] # Start MCP server for AI agent integration ``` +## MCP Server Integration + +OpenSpec provides an MCP (Model Context Protocol) server that enables AI coding assistants to access OpenSpec resources, tools, and prompts programmatically. + +### Starting the MCP Server + +Start the MCP server with: + +```bash +openspec mcp +``` + +Use the `--debug` flag to enable debug logging to stderr: + +```bash +openspec mcp --debug +``` + +The server runs on stdio and communicates via the MCP protocol. + +### MCP Client Configuration + +#### MCP.json + +Add OpenSpec to your using standard `mcp.json` from your favorite AI coding assistant: + +```json +{ + "mcpServers": { + "openspec": { + "command": "openspec", + "args": ["mcp"], + "env": { + "OPENSPEC_ROOT": "/path/to/your/openspec/root", + "OPENSPEC_AUTO_PROJECT_ROOT": "true" + } + } + } +} +``` + +### Environment Variables + +The MCP server supports the following environment variables for configuring spec storage: + +- **`OPENSPEC_ROOT`**: Centralized root directory for OpenSpec specs. When set, all specs are stored under this directory instead of in each project. +- **`OPENSPEC_AUTO_PROJECT_ROOT`**: When set to `"true"`, organizes specs under `OPENSPEC_ROOT` by project path. For example, if `OPENSPEC_ROOT=/home/user/.openspec` and your project is at `/home/user/code/myapp`, specs will be stored at `/home/user/.openspec/code/myapp/openspec/`. + +**Examples:** + +```bash +# Default: specs stored in project directory +openspec mcp + +# Specs at: ./openspec/ +# Fixed root: all specs in one location +OPENSPEC_ROOT=/home/user/.openspec openspec mcp + +# Specs at: /home/user/.openspec/code/myapp/openspec/ +# Auto project root: organized by project path +OPENSPEC_ROOT=/home/user/.openspec OPENSPEC_AUTO_PROJECT_ROOT=true openspec mcp +``` + +### MCP Resources + +The server exposes the following resources: + +- `openspec://instructions` - OpenSpec workflow instructions (AGENTS.md) +- `openspec://project` - Project context (project.md) +- `openspec://specs` - List of all specifications +- `openspec://specs/{capability}` - Individual spec content +- `openspec://changes` - List of active changes +- `openspec://changes/{changeId}` - Change details (proposal, tasks, design) +- `openspec://changes/{changeId}/proposal` - Proposal document +- `openspec://changes/{changeId}/tasks` - Tasks checklist +- `openspec://changes/{changeId}/design` - Design document +- `openspec://archive` - List of archived changes + +### MCP Tools + +The server exposes the following tools: + +- `init` - Initialize OpenSpec in a project +- `list` - List changes or specs +- `show` - Show change or spec details +- `validate` - Validate changes and specs +- `archive` - Archive a completed change +- `update_project_context` - Update project.md with project details +- `edit` - Edit OpenSpec files + +### MCP Prompts + +The server exposes the following prompts: + +- `openspec-propose` - Guided workflow for creating change proposals +- `openspec-apply` - Guided workflow for implementing changes +- `openspec-archive` - Guided workflow for archiving completed changes + ## Example: How AI Creates OpenSpec Files When you ask your AI assistant to "add two-factor authentication", it creates: diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md index 355969d8..28ca9bfd 100644 --- a/openspec/AGENTS.md +++ b/openspec/AGENTS.md @@ -52,9 +52,40 @@ Track these steps as TODOs and complete them one by one. 2. **Read design.md** (if exists) - Review technical decisions 3. **Read tasks.md** - Get implementation checklist 4. **Implement tasks sequentially** - Complete in order -5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses -6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality -7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved +5. **Add tests for new code** - Every new TypeScript file requires corresponding tests (see Testing Requirements below) +6. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +7. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +8. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Testing Requirements +All changes that add or modify TypeScript code MUST include corresponding tests: + +1. **Test file location** - Mirror the source structure under `test/`: + - `src/mcp/utils/path-resolver.ts` → `test/mcp/utils/path-resolver.test.ts` + - `src/commands/foo.ts` → `test/commands/foo.test.ts` + +2. **Test coverage expectations**: + - Unit tests for all exported functions + - Edge cases and error conditions + - Integration tests for cross-module behavior when applicable + +3. **Test patterns** - Follow existing conventions: + - Use `vitest` (describe, it, expect, vi for mocks) + - Use temporary directories for filesystem tests + - Clean up resources in `afterEach` hooks + - Handle platform differences (e.g., symlink resolution on macOS) + +4. **Running tests**: + ```bash + pnpm test # Run all tests + pnpm test test/mcp/ # Run tests for a specific module + pnpm test:coverage # Run with coverage report + ``` + +5. **Pre-archive verification** - Before archiving a change, ensure: + - All new code has tests + - `pnpm test` passes + - No regressions in existing tests ### Stage 3: Archiving Changes After deployment, create separate PR to: @@ -160,6 +191,8 @@ New request? 2. **Write proposal.md:** ```markdown +# Change: [Brief description of change] + ## Why [1-2 sentences on problem/opportunity] diff --git a/openspec/changes/archive/2025-12-21-add-cli-mcp/design.md b/openspec/changes/archive/2025-12-21-add-cli-mcp/design.md new file mode 100644 index 00000000..1871986d --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-cli-mcp/design.md @@ -0,0 +1,1671 @@ +# Technical Design for OpenSpec MCP Server Subcommand (v2) + +Integrating MCP protocol into the OpenSpec CLI creates a **stdio-based server** that exposes **10 resources**, **6 tools**, and **3 prompts** to AI coding assistants. This design achieves 1:1 parity with the OpenSpec CLI while leveraging MCP's resource model for context delivery. + +## Architecture Overview + +The MCP server becomes a first-class subcommand alongside `init`, `list`, and `validate`. The implementation follows MCP best practices: + +- **Resources**: Read-only access to specs, changes, project context, and AGENTS.md instructions +- **Tools**: Actions that modify state, mapping directly to CLI commands +- **Prompts**: Guided workflows that combine resources and tools + +## Exposed Capabilities Summary + +### Resources (10 total) + +| Resource URI | Description | +|--------------|-------------| +| `openspec://instructions` | AGENTS.md workflow instructions | +| `openspec://project` | Project context (project.md) | +| `openspec://specs` | List of all specifications | +| `openspec://specs/{capability}` | Individual spec content | +| `openspec://changes` | List of active changes | +| `openspec://changes/{changeId}` | Change details (proposal, tasks, design) | +| `openspec://changes/{changeId}/proposal` | Proposal document | +| `openspec://changes/{changeId}/tasks` | Tasks checklist | +| `openspec://changes/{changeId}/design` | Design document | +| `openspec://archive` | List of archived changes | + +### Tools (6 total) + +| Tool | CLI Command | Description | +|------|-------------|-------------| +| `init` | `openspec init [path]` | Initialize OpenSpec in project | +| `list` | `openspec list [--specs]` | List changes (default) or specs | +| `show` | `openspec show [item-name]` | Show a change or spec | +| `validate` | `openspec validate [item-name]` | Validate changes and specs | +| `archive` | `openspec archive [change-name]` | Archive completed change | +| `update_project_context` | *(new)* | Update project.md with project details | + +**Note**: The CLI `view` command (interactive dashboard) and `update` command are not exposed via MCP. The `change` and `spec` subcommand groups are accessed through `list`, `show`, and the prompts. + +### Prompts (3 total) + +| Prompt | Slash Command | Description | +|--------|---------------|-------------| +| `openspec-propose` | `/openspec:proposal` | Guided proposal workflow | +| `openspec-apply` | `/openspec:apply` | Guided implementation workflow | +| `openspec-archive` | `/openspec:archive` | Guided archive workflow | + +--- + +## File Structure + +``` +src/ +├── commands/ +│ └── mcp.ts # Commander.js subcommand entry point +│ +├── mcp/ +│ ├── index.ts # McpServer initialization and startup +│ ├── server.ts # Server configuration and connection handling +│ │ +│ ├── resources/ +│ │ ├── index.ts # Resource registration aggregator +│ │ ├── instructions.ts # AGENTS.md resource +│ │ ├── project.ts # project.md resource +│ │ ├── specs.ts # Specs list and individual spec resources +│ │ ├── changes.ts # Changes list and individual change resources +│ │ └── archive.ts # Archived changes resource +│ │ +│ ├── tools/ +│ │ ├── index.ts # Tool registration aggregator +│ │ ├── init.ts # openspec init +│ │ ├── list.ts # openspec list [--specs] +│ │ ├── show.ts # openspec show [item-name] +│ │ ├── validate.ts # openspec validate [item-name] +│ │ ├── archive.ts # openspec archive [change-name] +│ │ └── project.ts # update_project_context +│ │ +│ ├── prompts/ +│ │ ├── index.ts # Prompt registration aggregator +│ │ ├── propose-prompt.ts # Proposal workflow prompt +│ │ ├── apply-prompt.ts # Implementation workflow prompt +│ │ └── archive-prompt.ts # Archive workflow prompt +│ │ +│ └── utils/ +│ ├── path-resolver.ts # OPENSPEC_ROOT path resolution logic +│ ├── context-loader.ts # Loads AGENTS.md and project.md content +│ └── task-parser.ts # Parse and update tasks.md +``` + +--- + +## Commander.js Integration + +```typescript +// src/commands/mcp.ts +import { Command } from 'commander'; +import { startMcpServer } from '../mcp/index.js'; + +export function createMcpCommand(): Command { + return new Command('mcp') + .description('Start stdio-based MCP server for AI agent integration') + .option('--debug', 'Enable debug logging to stderr') + .action(async (options) => { + await startMcpServer({ + debug: options.debug ?? false, + }); + }); +} +``` + +--- + +## Path Resolution Logic + +```typescript +// src/mcp/utils/path-resolver.ts +import * as path from 'path'; +import * as fs from 'fs'; + +export interface PathConfig { + specsRoot: string; + projectRoot: string; + isAutoProjectRoot: boolean; +} + +export function resolveOpenSpecPaths(projectPath?: string): PathConfig { + const openspecRoot = process.env.OPENSPEC_ROOT; + const autoProjectRoot = process.env.OPENSPEC_AUTO_PROJECT_ROOT === 'true'; + + const projectRoot = projectPath + ? path.resolve(projectPath) + : process.cwd(); + + if (!openspecRoot) { + return { specsRoot: projectRoot, projectRoot, isAutoProjectRoot: false }; + } + + const resolvedOpenspecRoot = path.resolve(openspecRoot); + + if (!autoProjectRoot) { + return { specsRoot: resolvedOpenspecRoot, projectRoot, isAutoProjectRoot: false }; + } + + const relativeProjectPath = deriveRelativePath(projectRoot); + const specsRoot = path.join(resolvedOpenspecRoot, relativeProjectPath); + ensureDirectoryExists(specsRoot); + + return { specsRoot, projectRoot, isAutoProjectRoot: true }; +} + +function deriveRelativePath(projectRoot: string): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + + if (homeDir && projectRoot.startsWith(homeDir)) { + const relativePath = projectRoot.slice(homeDir.length); + return relativePath.replace(/^[\/\\]+/, ''); + } + + return path.basename(projectRoot); +} + +function ensureDirectoryExists(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +export function getOpenSpecDir(config: PathConfig): string { + return path.join(config.specsRoot, 'openspec'); +} + +export function getChangePath(config: PathConfig, changeId: string): string { + return path.join(getOpenSpecDir(config), 'changes', changeId); +} + +export function getSpecPath(config: PathConfig, capability: string): string { + return path.join(getOpenSpecDir(config), 'specs', capability, 'spec.md'); +} +``` + +### Path Resolution Examples + +| OPENSPEC_ROOT | AUTO_PROJECT_ROOT | Project Path | Specs Written To | +|---------------|-------------------|--------------|------------------| +| *(not set)* | *(any)* | `/home/foo/myproject` | `/home/foo/myproject/openspec/` | +| `/home/foo/.openspec` | `false` | `/home/foo/myproject` | `/home/foo/.openspec/openspec/` | +| `/home/foo/.openspec` | `true` | `/home/foo/project/bar` | `/home/foo/.openspec/project/bar/openspec/` | + +--- + +## MCP Server Initialization + +```typescript +// src/mcp/server.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { registerAllResources } from './resources/index.js'; +import { registerAllTools } from './tools/index.js'; +import { registerAllPrompts } from './prompts/index.js'; +import { resolveOpenSpecPaths } from './utils/path-resolver.js'; + +export async function startMcpServer(options: { debug: boolean }): Promise { + const pathConfig = resolveOpenSpecPaths(); + + if (options.debug) { + console.error('[openspec-mcp] Path configuration:', JSON.stringify(pathConfig, null, 2)); + } + + const server = new McpServer({ + name: 'openspec', + version: '1.0.0', + }); + + registerAllResources(server, pathConfig); + registerAllTools(server, pathConfig); + registerAllPrompts(server, pathConfig); + + const transport = new StdioServerTransport(); + + process.on('SIGINT', async () => { + await server.close(); + process.exit(0); + }); + + try { + await server.connect(transport); + console.error('[openspec-mcp] Server started on stdio'); + } catch (error) { + console.error('[openspec-mcp] Failed to start:', error); + process.exit(1); + } +} +``` + +--- + +## MCP Resources Implementation + +Resources provide read-only access to OpenSpec data. AI assistants fetch these to understand project context before calling tools. + +### Resource 1: Instructions (AGENTS.md) + +```typescript +// src/mcp/resources/instructions.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerInstructionsResource(server: McpServer, pathConfig: PathConfig): void { + server.resource( + 'openspec://instructions', + 'OpenSpec workflow instructions from AGENTS.md. AI assistants should read this first to understand how to use OpenSpec tools and prompts.', + async () => { + const agentsPath = path.join(getOpenSpecDir(pathConfig), 'AGENTS.md'); + + let content: string; + try { + content = await fs.readFile(agentsPath, 'utf-8'); + } catch { + content = getDefaultAgentsTemplate(); + } + + return { + contents: [{ + uri: 'openspec://instructions', + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); +} + +function getDefaultAgentsTemplate(): string { + return `# OpenSpec Workflow Instructions + +OpenSpec has not been initialized in this project. Use the \`init\` tool to get started. + +## Available Tools + +- \`init\`: Initialize OpenSpec in this project (openspec init) +- \`list\`: List changes or specs (openspec list [--specs]) +- \`show\`: Show a change or spec (openspec show [item-name]) +- \`validate\`: Validate changes and specs (openspec validate [item-name]) +- \`archive\`: Archive a completed change (openspec archive [change-name]) +- \`update_project_context\`: Update project.md with project details + +## Available Prompts + +- \`openspec-propose\`: Guided workflow for creating change proposals +- \`openspec-apply\`: Guided workflow for implementing changes +- \`openspec-archive\`: Guided workflow for archiving completed changes + +## Getting Started + +1. Run the \`init\` tool to initialize OpenSpec +2. Use \`update_project_context\` to fill in project details +3. Use the \`openspec-propose\` prompt to create your first change +`; +} +``` + +### Resource 2: Project Context (project.md) + +```typescript +// src/mcp/resources/project.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerProjectResource(server: McpServer, pathConfig: PathConfig): void { + server.resource( + 'openspec://project', + 'Project context including tech stack, conventions, and architectural patterns. Use update_project_context tool to modify.', + async () => { + const projectPath = path.join(getOpenSpecDir(pathConfig), 'project.md'); + + let content: string; + try { + content = await fs.readFile(projectPath, 'utf-8'); + } catch { + content = getEmptyProjectTemplate(); + } + + return { + contents: [{ + uri: 'openspec://project', + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); +} + +function getEmptyProjectTemplate(): string { + return `# Project Context + + + +## Project Description + +## Tech Stack + +## Conventions + +## Architecture + +## Testing +`; +} +``` + +### Resource 3-4: Specs Resources + +```typescript +// src/mcp/resources/specs.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir, getSpecPath } from '../utils/path-resolver.js'; + +export function registerSpecsResources(server: McpServer, pathConfig: PathConfig): void { + // List all specs + server.resource( + 'openspec://specs', + 'List of all specifications in openspec/specs/', + async () => { + const specsDir = path.join(getOpenSpecDir(pathConfig), 'specs'); + + let specs: string[] = []; + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + specs = entries.filter(e => e.isDirectory()).map(e => e.name); + } catch {} + + const content = specs.length > 0 + ? `# Specifications\n\n${specs.map(s => `- [${s}](openspec://specs/${s})`).join('\n')}` + : '# Specifications\n\nNo specifications found.'; + + return { + contents: [{ + uri: 'openspec://specs', + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); + + // Individual spec + server.resourceTemplate( + 'openspec://specs/{capability}', + 'Specification document for a specific capability', + async ({ capability }) => { + const specPath = getSpecPath(pathConfig, capability as string); + + let content: string; + try { + content = await fs.readFile(specPath, 'utf-8'); + } catch { + content = `# ${capability}\n\nSpecification not found.`; + } + + return { + contents: [{ + uri: `openspec://specs/${capability}`, + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); +} +``` + +### Resource 5-9: Changes Resources + +```typescript +// src/mcp/resources/changes.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir, getChangePath } from '../utils/path-resolver.js'; + +export function registerChangesResources(server: McpServer, pathConfig: PathConfig): void { + // List all active changes + server.resource( + 'openspec://changes', + 'List of all active change proposals', + async () => { + const changesDir = path.join(getOpenSpecDir(pathConfig), 'changes'); + + let changes: string[] = []; + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + changes = entries + .filter(e => e.isDirectory() && e.name !== 'archive') + .map(e => e.name); + } catch {} + + const content = changes.length > 0 + ? `# Active Changes\n\n${changes.map(c => `- [${c}](openspec://changes/${c})`).join('\n')}` + : '# Active Changes\n\nNo active changes.'; + + return { + contents: [{ + uri: 'openspec://changes', + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); + + // Individual change - returns all files + server.resourceTemplate( + 'openspec://changes/{changeId}', + 'Complete change details including proposal, tasks, and design', + async ({ changeId }) => { + const changePath = getChangePath(pathConfig, changeId as string); + + const [proposal, tasks, design] = await Promise.all([ + readFileIfExists(path.join(changePath, 'proposal.md')), + readFileIfExists(path.join(changePath, 'tasks.md')), + readFileIfExists(path.join(changePath, 'design.md')), + ]); + + const contents = []; + if (proposal) contents.push({ uri: `openspec://changes/${changeId}/proposal`, mimeType: 'text/markdown', text: proposal }); + if (tasks) contents.push({ uri: `openspec://changes/${changeId}/tasks`, mimeType: 'text/markdown', text: tasks }); + if (design) contents.push({ uri: `openspec://changes/${changeId}/design`, mimeType: 'text/markdown', text: design }); + + if (contents.length === 0) { + contents.push({ uri: `openspec://changes/${changeId}`, mimeType: 'text/markdown', text: `# ${changeId}\n\nChange not found.` }); + } + + return { contents }; + } + ); + + // Individual change files + server.resourceTemplate('openspec://changes/{changeId}/proposal', 'Proposal document', async ({ changeId }) => { + const content = await readFileIfExists(path.join(getChangePath(pathConfig, changeId as string), 'proposal.md')); + return { contents: [{ uri: `openspec://changes/${changeId}/proposal`, mimeType: 'text/markdown', text: content || 'Not found' }] }; + }); + + server.resourceTemplate('openspec://changes/{changeId}/tasks', 'Tasks checklist', async ({ changeId }) => { + const content = await readFileIfExists(path.join(getChangePath(pathConfig, changeId as string), 'tasks.md')); + return { contents: [{ uri: `openspec://changes/${changeId}/tasks`, mimeType: 'text/markdown', text: content || 'Not found' }] }; + }); + + server.resourceTemplate('openspec://changes/{changeId}/design', 'Design document', async ({ changeId }) => { + const content = await readFileIfExists(path.join(getChangePath(pathConfig, changeId as string), 'design.md')); + return { contents: [{ uri: `openspec://changes/${changeId}/design`, mimeType: 'text/markdown', text: content || 'Not found' }] }; + }); +} + +async function readFileIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return null; + } +} +``` + +### Resource 10: Archive + +```typescript +// src/mcp/resources/archive.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerArchiveResource(server: McpServer, pathConfig: PathConfig): void { + server.resource( + 'openspec://archive', + 'List of archived (completed) changes', + async () => { + const archiveDir = path.join(getOpenSpecDir(pathConfig), 'changes', 'archive'); + + let archived: string[] = []; + try { + const entries = await fs.readdir(archiveDir, { withFileTypes: true }); + archived = entries.filter(e => e.isDirectory()).map(e => e.name).sort().reverse(); + } catch {} + + const content = archived.length > 0 + ? `# Archived Changes\n\n${archived.map(a => `- ${a}`).join('\n')}` + : '# Archived Changes\n\nNo archived changes yet.'; + + return { + contents: [{ + uri: 'openspec://archive', + mimeType: 'text/markdown', + text: content, + }], + }; + } + ); +} +``` + +--- + +## MCP Tools Implementation + +Tools provide actions that map directly to OpenSpec CLI commands. Each tool returns structured JSON responses. + +### Tool 1: `init` - Initialize OpenSpec + +Maps to: `openspec init [options] [path]` + +```typescript +// src/mcp/tools/init.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerInitTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'init', + `Initialize OpenSpec in the project. Creates openspec/ directory with AGENTS.md, project.md, and folder structure. + +Equivalent to: openspec init [path] + +After initialization: +1. Read openspec://instructions for workflow guidance +2. Use update_project_context to fill in project details +3. Use openspec-propose prompt to create your first change`, + { + path: z.string().optional() + .describe('Path to initialize OpenSpec in (defaults to current directory)'), + tools: z.array(z.enum(['claude-code', 'cursor', 'codex', 'codebuddy', 'qoder', 'roocode'])) + .optional() + .describe('AI tools to configure slash commands for'), + }, + async (args) => { + try { + const openspecDir = getOpenSpecDir(pathConfig); + + try { + await fs.access(openspecDir); + return { + content: [{ type: 'text', text: JSON.stringify({ + success: false, + error: 'OpenSpec already initialized', + path: openspecDir, + hint: 'Read openspec://instructions to see available workflows' + }, null, 2) }], + isError: true, + }; + } catch {} + + await fs.mkdir(openspecDir, { recursive: true }); + await fs.mkdir(path.join(openspecDir, 'specs'), { recursive: true }); + await fs.mkdir(path.join(openspecDir, 'changes'), { recursive: true }); + await fs.mkdir(path.join(openspecDir, 'changes', 'archive'), { recursive: true }); + + await fs.writeFile(path.join(openspecDir, 'AGENTS.md'), getAgentsTemplate()); + await fs.writeFile(path.join(openspecDir, 'project.md'), getProjectTemplate()); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + path: openspecDir, + filesCreated: ['AGENTS.md', 'project.md', 'specs/', 'changes/', 'changes/archive/'], + nextSteps: [ + 'Read openspec://instructions for workflow guidance', + 'Read openspec://project to see the empty project template', + 'Use update_project_context tool to fill in project details', + 'Use openspec-propose prompt to create your first change' + ], + }, null, 2), + }], + }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true }; + } + } + ); +} + +function getAgentsTemplate(): string { + return `# OpenSpec Agent Instructions + +// ... template content +`; +} + +function getProjectTemplate(): string { + return `# Project Context + +## Project Description + + +## Tech Stack + + +## Conventions + + +## Architecture + + +## Testing + +`; +} +``` + +### Tool 2: `list` - List Changes or Specs + +Maps to: `openspec list [options]` + +```typescript +// src/mcp/tools/list.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerListTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'list', + `List items - changes by default, or specs with --specs flag. + +Equivalent to: openspec list [--specs] + +Before using: +1. Read openspec://instructions for context +2. Read openspec://project for project conventions`, + { + specs: z.boolean().default(false) + .describe('List specs instead of changes'), + long: z.boolean().default(false) + .describe('Show detailed information'), + }, + async (args) => { + try { + if (args.specs) { + // List specs + const specsDir = path.join(getOpenSpecDir(pathConfig), 'specs'); + let specs: Array<{ capability: string; summary?: string }> = []; + + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const spec: { capability: string; summary?: string } = { capability: entry.name }; + if (args.long) { + try { + const content = await fs.readFile(path.join(specsDir, entry.name, 'spec.md'), 'utf-8'); + spec.summary = content.slice(0, 200).replace(/\n/g, ' ') + '...'; + } catch {} + } + specs.push(spec); + } + } + } catch {} + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + type: 'specs', + count: specs.length, + specs, + hint: 'Use show tool with capability name for full spec content' + }, null, 2) + }] + }; + } else { + // List changes + const changesDir = path.join(getOpenSpecDir(pathConfig), 'changes'); + let changes: Array<{ id: string; hasProposal: boolean; hasTasks: boolean; hasDesign: boolean; progress?: { completed: number; total: number } }> = []; + + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'archive') { + const changePath = path.join(changesDir, entry.name); + const change: any = { + id: entry.name, + hasProposal: await fileExists(path.join(changePath, 'proposal.md')), + hasTasks: await fileExists(path.join(changePath, 'tasks.md')), + hasDesign: await fileExists(path.join(changePath, 'design.md')), + }; + + if (args.long && change.hasTasks) { + try { + const tasks = await fs.readFile(path.join(changePath, 'tasks.md'), 'utf-8'); + const all = tasks.match(/- \[[ x]\]/gi) || []; + const done = tasks.match(/- \[x\]/gi) || []; + change.progress = { completed: done.length, total: all.length }; + } catch {} + } + + changes.push(change); + } + } + } catch {} + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + type: 'changes', + count: changes.length, + changes, + hint: 'Use show tool with change name for full details' + }, null, 2) + }] + }; + } + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true }; + } + } + ); +} + +async function fileExists(p: string): Promise { + try { await fs.access(p); return true; } catch { return false; } +} +``` + +### Tool 3: `show` - Show Change or Spec + +Maps to: `openspec show [options] [item-name]` + +```typescript +// src/mcp/tools/show.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getChangePath, getSpecPath, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerShowTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'show', + `Show details of a change or spec. + +Equivalent to: openspec show [item-name] + +Before using: +1. Read openspec://instructions for workflow context +2. Read openspec://project for project conventions +3. Use list tool to see available items`, + { + name: z.string() + .describe('Name of the change or spec to show'), + type: z.enum(['change', 'spec']).default('change') + .describe('Type of item to show'), + }, + async (args) => { + try { + if (args.type === 'spec') { + // Show spec + const specPath = getSpecPath(pathConfig, args.name); + + let content: string; + try { + content = await fs.readFile(specPath, 'utf-8'); + } catch { + return { + content: [{ type: 'text', text: JSON.stringify({ + error: `Spec "${args.name}" not found`, + hint: 'Use list tool with specs=true to see available specs' + }, null, 2) }], + isError: true + }; + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + type: 'spec', + capability: args.name, + content + }, null, 2) + }] + }; + } else { + // Show change + const changePath = getChangePath(pathConfig, args.name); + + try { + await fs.access(changePath); + } catch { + return { + content: [{ type: 'text', text: JSON.stringify({ + error: `Change "${args.name}" not found`, + hint: 'Use list tool to see available changes' + }, null, 2) }], + isError: true + }; + } + + const [proposal, tasks, design] = await Promise.all([ + readFileIfExists(path.join(changePath, 'proposal.md')), + readFileIfExists(path.join(changePath, 'tasks.md')), + readFileIfExists(path.join(changePath, 'design.md')), + ]); + + const specDeltas = await readSpecDeltas(path.join(changePath, 'specs')); + const taskProgress = parseTaskProgress(tasks || ''); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + type: 'change', + changeId: args.name, + progress: taskProgress, + files: { + proposal: proposal ? 'present' : 'missing', + tasks: tasks ? 'present' : 'missing', + design: design ? 'present' : 'missing', + }, + proposal, + tasks, + design, + specDeltas, + }, null, 2), + }], + }; + } + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true }; + } + } + ); +} + +async function readFileIfExists(p: string): Promise { + try { return await fs.readFile(p, 'utf-8'); } catch { return null; } +} + +async function readSpecDeltas(dir: string): Promise> { + const deltas: Record = {}; + try { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const content = await readFileIfExists(path.join(dir, entry.name, 'spec.md')); + if (content) deltas[entry.name] = content; + } + } + } catch {} + return deltas; +} + +function parseTaskProgress(content: string) { + const all = content.match(/- \[[ x]\]/gi) || []; + const done = content.match(/- \[x\]/gi) || []; + return { total: all.length, completed: done.length, percentage: all.length ? Math.round((done.length / all.length) * 100) : 0 }; +} +``` + +### Tool 4: `validate` - Validate Changes and Specs + +Maps to: `openspec validate [options] [item-name]` + +```typescript +// src/mcp/tools/validate.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir, getChangePath } from '../utils/path-resolver.js'; + +export function registerValidateTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'validate', + `Validate changes and specs for formatting and structure. + +Equivalent to: openspec validate [item-name] + +Before using: +1. Read openspec://instructions for validation rules +2. Read openspec://project for project-specific conventions`, + { + name: z.string().optional() + .describe('Specific change or spec to validate. If omitted, validates all.'), + type: z.enum(['change', 'spec', 'all']).default('all') + .describe('Type of item to validate'), + strict: z.boolean().default(false) + .describe('Fail on warnings (not just errors)'), + }, + async (args) => { + try { + const errors: string[] = []; + const warnings: string[] = []; + + if (args.name && args.type === 'change') { + // Validate specific change + const changePath = getChangePath(pathConfig, args.name); + + try { + await fs.access(changePath); + } catch { + return { + content: [{ type: 'text', text: JSON.stringify({ + valid: false, + error: `Change "${args.name}" not found` + }, null, 2) }], + isError: true + }; + } + + // Check required files + if (!await fileExists(path.join(changePath, 'proposal.md'))) { + errors.push('Missing proposal.md'); + } else { + const proposal = await fs.readFile(path.join(changePath, 'proposal.md'), 'utf-8'); + if (!proposal.includes('## Summary')) warnings.push('proposal.md missing Summary section'); + if (!proposal.includes('## Why')) warnings.push('proposal.md missing Why section'); + } + + if (!await fileExists(path.join(changePath, 'tasks.md'))) { + errors.push('Missing tasks.md'); + } else { + const tasks = await fs.readFile(path.join(changePath, 'tasks.md'), 'utf-8'); + if (!tasks.match(/- \[[ x]\]/)) warnings.push('tasks.md has no task checkboxes'); + } + + // Validate spec deltas + const specsDir = path.join(changePath, 'specs'); + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !await fileExists(path.join(specsDir, entry.name, 'spec.md'))) { + warnings.push(`specs/${entry.name}/ missing spec.md`); + } + } + } catch {} + + } else if (args.name && args.type === 'spec') { + // Validate specific spec + const specPath = path.join(getOpenSpecDir(pathConfig), 'specs', args.name, 'spec.md'); + try { + const content = await fs.readFile(specPath, 'utf-8'); + if (content.trim().length === 0) errors.push('spec.md is empty'); + } catch { + errors.push(`Spec "${args.name}" not found`); + } + + } else { + // Validate all + const specsDir = path.join(getOpenSpecDir(pathConfig), 'specs'); + const changesDir = path.join(getOpenSpecDir(pathConfig), 'changes'); + + // Validate all specs + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const specPath = path.join(specsDir, entry.name, 'spec.md'); + try { + const content = await fs.readFile(specPath, 'utf-8'); + if (content.trim().length === 0) warnings.push(`specs/${entry.name}/spec.md is empty`); + } catch { + errors.push(`specs/${entry.name}/ missing spec.md`); + } + } + } + } catch { + warnings.push('No specs directory found'); + } + + // Validate all changes + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name !== 'archive') { + const changePath = path.join(changesDir, entry.name); + if (!await fileExists(path.join(changePath, 'proposal.md'))) { + errors.push(`changes/${entry.name}/ missing proposal.md`); + } + if (!await fileExists(path.join(changePath, 'tasks.md'))) { + warnings.push(`changes/${entry.name}/ missing tasks.md`); + } + } + } + } catch {} + } + + const valid = errors.length === 0 && (!args.strict || warnings.length === 0); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + valid, + target: args.name || 'all', + strict: args.strict, + errors, + warnings + }, null, 2) + }], + isError: !valid + }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true }; + } + } + ); +} + +async function fileExists(p: string): Promise { + try { await fs.access(p); return true; } catch { return false; } +} +``` + +### Tool 5: `archive` - Archive Completed Change + +Maps to: `openspec archive [options] [change-name]` + +```typescript +// src/mcp/tools/archive.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getChangePath, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerArchiveTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'archive', + `Archive a completed change and update main specs. + +Equivalent to: openspec archive [change-name] + +Before using: +1. Read openspec://instructions for archive workflow +2. Read openspec://project for project conventions +3. Use show tool to verify all tasks are complete +4. Use validate tool to ensure no errors`, + { + name: z.string() + .describe('Name of the change to archive'), + updateSpecs: z.boolean().default(true) + .describe('Merge spec deltas into main specs/'), + dryRun: z.boolean().default(false) + .describe('Preview without making changes'), + force: z.boolean().default(false) + .describe('Archive even if tasks are incomplete'), + }, + async (args) => { + try { + const openspecDir = getOpenSpecDir(pathConfig); + const changePath = getChangePath(pathConfig, args.name); + const archiveDir = path.join(openspecDir, 'changes', 'archive'); + + try { + await fs.access(changePath); + } catch { + return { + content: [{ type: 'text', text: JSON.stringify({ + success: false, + error: `Change "${args.name}" not found`, + hint: 'Use list tool to see available changes' + }, null, 2) }], + isError: true + }; + } + + // Check tasks complete + if (!args.force) { + try { + const tasks = await fs.readFile(path.join(changePath, 'tasks.md'), 'utf-8'); + const incomplete = (tasks.match(/- \[ \]/g) || []).length; + if (incomplete > 0) { + return { + content: [{ type: 'text', text: JSON.stringify({ + success: false, + error: 'Incomplete tasks remain', + incompleteTasks: incomplete, + hint: 'Complete all tasks or use force=true to archive anyway' + }, null, 2) }], + isError: true + }; + } + } catch {} + } + + const datePrefix = new Date().toISOString().split('T')[0]; + const archiveName = `${datePrefix}-${args.name}`; + const archivePath = path.join(archiveDir, archiveName); + + if (args.dryRun) { + return { + content: [{ type: 'text', text: JSON.stringify({ + dryRun: true, + changeId: args.name, + operations: [ + `Move ${changePath} → ${archivePath}`, + args.updateSpecs ? 'Merge spec deltas into specs/' : 'Skip spec updates' + ], + hint: 'Set dryRun=false to execute' + }, null, 2) }] + }; + } + + await fs.mkdir(archiveDir, { recursive: true }); + + // Merge spec deltas if requested + if (args.updateSpecs) { + await mergeSpecDeltas(changePath, openspecDir); + } + + await fs.rename(changePath, archivePath); + + return { + content: [{ type: 'text', text: JSON.stringify({ + success: true, + changeId: args.name, + archivedTo: archivePath, + specsUpdated: args.updateSpecs, + nextSteps: [ + 'Use validate tool to verify specs', + 'Commit the updated specs to version control' + ] + }, null, 2) }] + }; + } catch (error) { + return { content: [{ type: 'text', text: `Error: ${error}` }], isError: true }; + } + } + ); +} + +async function mergeSpecDeltas(changePath: string, openspecDir: string): Promise { + const deltaDir = path.join(changePath, 'specs'); + const mainDir = path.join(openspecDir, 'specs'); + + try { + for (const entry of await fs.readdir(deltaDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + const deltaPath = path.join(deltaDir, entry.name, 'spec.md'); + const mainPath = path.join(mainDir, entry.name, 'spec.md'); + + try { + const delta = await fs.readFile(deltaPath, 'utf-8'); + await fs.mkdir(path.join(mainDir, entry.name), { recursive: true }); + + let existing = ''; + try { existing = await fs.readFile(mainPath, 'utf-8'); } catch {} + + const merged = existing ? `${existing}\n\n---\n\n${delta}` : delta; + await fs.writeFile(mainPath, merged); + } catch {} + } + } + } catch {} +} +``` + +### Tool 6: `update_project_context` - Update Project Context + +This is a new tool (not in CLI) that enables AI assistants to populate project.md after analyzing the codebase. + +```typescript +// src/mcp/tools/project.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { PathConfig, getOpenSpecDir } from '../utils/path-resolver.js'; + +export function registerProjectTool(server: McpServer, pathConfig: PathConfig): void { + server.tool( + 'update_project_context', + `Update the project.md file with project details. + +This tool allows AI assistants to populate or update the project context after analyzing the codebase. + +**Typical workflow:** +1. Read openspec://instructions for guidance +2. Read openspec://project to see current state +3. Analyze the codebase to understand tech stack, conventions, patterns +4. Call this tool with updates to apply + +**Expected content sections:** +- Project Description: What the project does +- Tech Stack: Languages, frameworks, databases, etc. +- Conventions: Coding standards, naming conventions, patterns +- Architecture: High-level structure, key modules +- Testing: Testing approach and requirements + +**Example prompt to trigger this workflow:** +"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"`, + { + content: z.string() + .describe('Complete markdown content for project.md'), + + merge: z.boolean().default(false) + .describe('If true, merge with existing content instead of replacing'), + + section: z.string().optional() + .describe('If provided with merge=true, only update this section (e.g., "Tech Stack")'), + }, + async (args) => { + try { + const projectPath = path.join(getOpenSpecDir(pathConfig), 'project.md'); + + let finalContent: string; + + if (args.merge) { + let existing = ''; + try { + existing = await fs.readFile(projectPath, 'utf-8'); + } catch {} + + if (args.section && existing) { + // Update only the specified section + finalContent = updateSection(existing, args.section, args.content); + } else if (existing) { + // Merge: append new content + finalContent = `${existing}\n\n---\n\n## Updates\n\n${args.content}`; + } else { + finalContent = args.content; + } + } else { + // Replace entirely + finalContent = args.content; + } + + await fs.mkdir(path.dirname(projectPath), { recursive: true }); + await fs.writeFile(projectPath, finalContent); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + success: true, + path: projectPath, + mode: args.merge ? (args.section ? `merged section: ${args.section}` : 'merged') : 'replaced', + contentLength: finalContent.length, + nextSteps: [ + 'Read openspec://project to verify the updates', + 'Use openspec-propose prompt to create changes based on project context' + ] + }, null, 2), + }], + }; + } catch (error) { + return { + content: [{ + type: 'text', + text: `Error updating project context: ${error instanceof Error ? error.message : String(error)}`, + }], + isError: true, + }; + } + } + ); +} + +/** + * Updates a specific section in markdown content. + */ +function updateSection(existing: string, sectionName: string, newContent: string): string { + const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`(## ${escaped}\\n)([\\s\\S]*?)(?=\\n## |\\n# |$)`, 'i'); + + const match = existing.match(pattern); + + if (match) { + return existing.replace(pattern, `## ${sectionName}\n\n${newContent}\n\n`); + } else { + return `${existing}\n\n## ${sectionName}\n\n${newContent}`; + } +} +``` + +--- + +## MCP Prompts Implementation + +Prompts provide guided workflows that combine resources and tools. **Each prompt instructs the AI to first read the instructions and project resources before proceeding.** + +### Prompt 1: `openspec-propose` - Create Change Proposal + +```typescript +// src/mcp/prompts/propose-prompt.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { PathConfig } from '../utils/path-resolver.js'; + +export function registerProposePrompt(server: McpServer, pathConfig: PathConfig): void { + server.prompt( + 'openspec-propose', + 'Guided workflow for creating a new OpenSpec change proposal. Equivalent to /openspec:proposal slash command.', + { + description: z.string().describe('What you want to build or change'), + scope: z.enum(['new-capability', 'modify-existing', 'refactor', 'fix']).optional(), + }, + async ({ description, scope }) => { + return { + messages: [{ + role: 'user', + content: { + type: 'text', + text: `# OpenSpec Proposal Workflow + +## Your Task +Create a new OpenSpec change proposal for: "${description}" +${scope ? `Scope: ${scope}` : ''} + +## Step 1: Load Context (REQUIRED) + +**You MUST read these resources before proceeding:** + +1. Read \`openspec://instructions\` - Understand the OpenSpec workflow and conventions +2. Read \`openspec://project\` - Understand the project's tech stack, conventions, and architecture +3. Read \`openspec://specs\` - See existing specifications to avoid conflicts +4. Read \`openspec://changes\` - See active changes to avoid duplicates + +## Step 2: Analyze the Request + +Based on the context you loaded: +- Identify which existing specs might be affected +- Determine if this conflicts with any active changes +- Consider the project's conventions and architecture + +## Step 3: Create the Proposal + +Choose a unique change-id (kebab-case, verb-led): +- \`add-*\` for new features (e.g., add-user-auth) +- \`update-*\` for modifications (e.g., update-payment-flow) +- \`remove-*\` for deprecations +- \`refactor-*\` for restructuring +- \`fix-*\` for bug fixes + +Create the change directory structure manually or describe what files to create: +- \`openspec/changes//proposal.md\` - The proposal document +- \`openspec/changes//tasks.md\` - Task breakdown +- \`openspec/changes//design.md\` - Technical design (optional) +- \`openspec/changes//specs//spec.md\` - Spec deltas + +## Step 4: Validate + +Use the \`validate\` tool to check the proposal: +- \`validate\` with name= and type='change' + +Begin by reading the required resources.`, + }, + }], + }; + } + ); +} +``` + +### Prompt 2: `openspec-apply` - Implement Change + +```typescript +// src/mcp/prompts/apply-prompt.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { PathConfig } from '../utils/path-resolver.js'; + +export function registerApplyPrompt(server: McpServer, pathConfig: PathConfig): void { + server.prompt( + 'openspec-apply', + 'Guided workflow for implementing an approved OpenSpec change. Equivalent to /openspec:apply slash command.', + { + changeId: z.string().describe('The change identifier to implement'), + }, + async ({ changeId }) => { + return { + messages: [{ + role: 'user', + content: { + type: 'text', + text: `# OpenSpec Implementation Workflow + +## Implement Change: ${changeId} + +## Step 1: Load Context (REQUIRED) + +**You MUST read these resources before proceeding:** + +1. Read \`openspec://instructions\` - Understand the implementation workflow +2. Read \`openspec://project\` - Understand the project's conventions and architecture +3. Read \`openspec://changes/${changeId}\` - Load the full change details (proposal, tasks, design) + +## Step 2: Understand the Scope + +From the change resources: +- Review proposal.md to understand what's being built and why +- Review design.md (if present) for technical decisions +- Review tasks.md to see the implementation checklist +- Note which specifications are affected + +## Step 3: Implement Tasks Sequentially + +Follow the tasks in order: +1. Read the current task description +2. Implement the required changes +3. Update tasks.md to mark the task complete (change \`[ ]\` to \`[x]\`) +4. Move to the next task + +Keep progress synchronized - update the task status as you complete each item. + +## Step 4: Apply Spec Changes + +For each spec delta in the change: +- Review the ADDED, MODIFIED, and REMOVED requirements +- Ensure your implementation matches the specification +- Verify scenarios are testable + +## Step 5: Validate Continuously + +After completing significant work: +- Use \`validate\` tool with name="${changeId}" and type='change' +- Fix any errors or warnings before proceeding + +## Step 6: Complete + +When all tasks are done: +- Verify all task checkboxes are marked \`[x]\` +- Run final validation +- The change is ready for the archive workflow + +Begin by reading the required resources.`, + }, + }], + }; + } + ); +} +``` + +### Prompt 3: `openspec-archive` - Archive Completed Change + +```typescript +// src/mcp/prompts/archive-prompt.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { PathConfig } from '../utils/path-resolver.js'; + +export function registerArchivePrompt(server: McpServer, pathConfig: PathConfig): void { + server.prompt( + 'openspec-archive', + 'Guided workflow for archiving a completed change. Equivalent to /openspec:archive slash command.', + { + changeId: z.string().describe('The change identifier to archive'), + }, + async ({ changeId }) => { + return { + messages: [{ + role: 'user', + content: { + type: 'text', + text: `# OpenSpec Archive Workflow + +## Archive Change: ${changeId} + +## Step 1: Load Context (REQUIRED) + +**You MUST read these resources before proceeding:** + +1. Read \`openspec://instructions\` - Understand the archive workflow +2. Read \`openspec://project\` - Understand project conventions +3. Read \`openspec://changes/${changeId}\` - Verify the change is complete + +## Step 2: Verify Completion + +Use the \`show\` tool to check the change: +- \`show\` with name="${changeId}" and type='change' + +Verify: +- All tasks are marked complete (\`[x]\`) +- Progress shows 100% +- All required files are present + +## Step 3: Run Final Validation + +Use the \`validate\` tool: +- \`validate\` with name="${changeId}" and type='change' and strict=true + +Ensure there are no errors or warnings. + +## Step 4: Preview Archive + +Use the \`archive\` tool with dryRun=true: +- \`archive\` with name="${changeId}" and dryRun=true + +Review the operations that will be performed. + +## Step 5: Execute Archive + +Use the \`archive\` tool: +- \`archive\` with name="${changeId}" and updateSpecs=true and dryRun=false + +This will: +- Move the change to \`changes/archive/YYYY-MM-DD-${changeId}/\` +- Merge spec deltas into the main \`specs/\` directory + +## Step 6: Post-Archive Validation + +Use the \`validate\` tool to verify all specs: +- \`validate\` with type='all' + +Confirm the merged specs are valid. + +## Step 7: Report Completion + +- Note the archive location +- Confirm specs were updated successfully +- Remind to commit changes to version control + +Begin by reading the required resources.`, + }, + }], + }; + } + ); +} +``` + +--- + +## MCP Client Configuration + +```json +{ + "mcpServers": { + "openspec": { + "command": "openspec", + "args": ["mcp"], + "env": { + "OPENSPEC_ROOT": "/home/user/.openspec", + "OPENSPEC_AUTO_PROJECT_ROOT": "true" + } + } + } +} +``` + +--- + +## CLI to MCP Mapping Summary + +| CLI Command | MCP Tool | MCP Resource | +|-------------|----------|--------------| +| `openspec init [path]` | `init` | - | +| `openspec list` | `list` | `openspec://changes` | +| `openspec list --specs` | `list` (specs=true) | `openspec://specs` | +| `openspec show [item]` | `show` | `openspec://changes/{id}`, `openspec://specs/{cap}` | +| `openspec validate [item]` | `validate` | - | +| `openspec archive [change]` | `archive` | `openspec://archive` | +| `openspec view` | *(not exposed - interactive)* | - | +| `openspec update` | *(not exposed - handled by MCP server update)* | - | +| *(read AGENTS.md)* | - | `openspec://instructions` | +| *(read project.md)* | - | `openspec://project` | +| *(update project.md)* | `update_project_context` | - | +| `/openspec:proposal` | - | `openspec-propose` prompt | +| `/openspec:apply` | - | `openspec-apply` prompt | +| `/openspec:archive` | - | `openspec-archive` prompt | + +--- + +## Key Design Decisions + +### Why Resources + Tools (not just Tools) + +MCP Resources provide **read-only context** that AI assistants can fetch without side effects: +- Efficient context loading (fetch once, use many times) +- Clear separation between reading (resources) and writing (tools) +- Standard MCP pattern followed by other servers +- **Prompts explicitly require reading resources first** to ensure AI has proper context + +### Why 6 Tools Match the CLI + +Each tool maps directly to an OpenSpec CLI command: +- `init` → `openspec init` +- `list` → `openspec list` (with --specs flag) +- `show` → `openspec show` +- `validate` → `openspec validate` +- `archive` → `openspec archive` +- `update_project_context` → *(new, for maintaining project.md)* + +The `view` command is interactive and not suitable for MCP. The `update` command is handled by updating the MCP server itself. + +### Why `update_project_context` as a Tool + +The project.md file needs to be populated with project-specific information. This tool enables AI assistants to: +1. Read `openspec://instructions` for guidance +2. Read `openspec://project` to see current state +3. Analyze the codebase to understand conventions +4. Write updates via the tool + +This supports the workflow triggered by: +``` +"Please read openspec/project.md and help me fill it out +with details about my project, tech stack, and conventions" +``` + +### Why Prompts Load Resources First + +Every prompt explicitly instructs the AI to: +1. Read `openspec://instructions` - workflow guidance +2. Read `openspec://project` - project context and conventions +3. Read relevant change/spec resources + +This ensures the AI always has proper context before taking action, leading to better-informed proposals and implementations. + +### Why Path Resolution with Environment Variables + +Different teams have different preferences: +- **Default** (no env vars): Specs in project directory - simple, self-contained +- **Fixed root**: Centralized spec storage - shared across projects +- **Auto-project-root**: Centralized but organized by project path - best of both worlds + diff --git a/openspec/changes/archive/2025-12-21-add-cli-mcp/proposal.md b/openspec/changes/archive/2025-12-21-add-cli-mcp/proposal.md new file mode 100644 index 00000000..40d218cd --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-cli-mcp/proposal.md @@ -0,0 +1,29 @@ +# Change: Add MCP Server Subcommand + +## Why + +AI coding assistants using MCP (Model Context Protocol) need programmatic access to OpenSpec's capabilities. Currently, assistants must rely on shell commands. An MCP server provides: + +- Structured resource access for specs, changes, and project context +- Tool invocations that map directly to CLI commands with JSON responses +- Guided prompts for proposal/apply/archive workflows +- Native integration with MCP-compatible AI tools (Claude Desktop, Cursor, etc.) + +## What Changes + +- Add new `openspec mcp` subcommand that starts a stdio-based MCP server +- Expose **10 resources** for read-only access to OpenSpec data +- Expose **6 tools** that map to CLI commands (init, list, show, validate, archive, update_project_context) +- Expose **3 prompts** for guided workflows (propose, apply, archive) +- Add new `src/mcp/` directory with modular implementation +- Add `@modelcontextprotocol/sdk` as a dependency + +## Impact + +- Affected specs: None (new capability) +- Affected code: + - `src/cli/index.ts` - Register new mcp command + - `src/commands/mcp.ts` - Commander.js subcommand entry point (new) + - `src/mcp/` - New directory with MCP server implementation +- New dependency: `@modelcontextprotocol/sdk` + diff --git a/openspec/changes/archive/2025-12-21-add-cli-mcp/specs/cli-mcp/spec.md b/openspec/changes/archive/2025-12-21-add-cli-mcp/specs/cli-mcp/spec.md new file mode 100644 index 00000000..973d6988 --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-cli-mcp/specs/cli-mcp/spec.md @@ -0,0 +1,212 @@ +## ADDED Requirements + +### Requirement: MCP Server Subcommand + +The CLI SHALL provide an `openspec mcp` subcommand that starts a stdio-based MCP (Model Context Protocol) server for AI agent integration. + +#### Scenario: Starting the MCP server + +- **WHEN** executing `openspec mcp` +- **THEN** start a stdio-based MCP server +- **AND** register all resources, tools, and prompts +- **AND** output startup confirmation to stderr: `[openspec-mcp] Server started on stdio` + +#### Scenario: Debug mode + +- **WHEN** executing `openspec mcp --debug` +- **THEN** enable debug logging to stderr +- **AND** output path configuration details + +#### Scenario: Graceful shutdown + +- **WHEN** receiving SIGINT signal +- **THEN** close the MCP server gracefully +- **AND** exit with code 0 + +### Requirement: MCP Resources + +The MCP server SHALL expose 10 read-only resources for accessing OpenSpec data. + +#### Scenario: Instructions resource + +- **WHEN** fetching `openspec://instructions` +- **THEN** return the content of `openspec/AGENTS.md` +- **AND** return a default template if the file does not exist + +#### Scenario: Project resource + +- **WHEN** fetching `openspec://project` +- **THEN** return the content of `openspec/project.md` +- **AND** return an empty template if the file does not exist + +#### Scenario: Specs list resource + +- **WHEN** fetching `openspec://specs` +- **THEN** return a markdown list of all specification directories in `openspec/specs/` +- **AND** include links to individual spec resources + +#### Scenario: Individual spec resource + +- **WHEN** fetching `openspec://specs/{capability}` +- **THEN** return the content of `openspec/specs/{capability}/spec.md` +- **AND** return a "not found" message if the spec does not exist + +#### Scenario: Changes list resource + +- **WHEN** fetching `openspec://changes` +- **THEN** return a markdown list of all active change directories +- **AND** exclude the `archive/` subdirectory +- **AND** include links to individual change resources + +#### Scenario: Individual change resource + +- **WHEN** fetching `openspec://changes/{changeId}` +- **THEN** return all available files (proposal.md, tasks.md, design.md) as multiple content items +- **AND** return a "not found" message if the change does not exist + +#### Scenario: Change proposal resource + +- **WHEN** fetching `openspec://changes/{changeId}/proposal` +- **THEN** return the content of `openspec/changes/{changeId}/proposal.md` + +#### Scenario: Change tasks resource + +- **WHEN** fetching `openspec://changes/{changeId}/tasks` +- **THEN** return the content of `openspec/changes/{changeId}/tasks.md` + +#### Scenario: Change design resource + +- **WHEN** fetching `openspec://changes/{changeId}/design` +- **THEN** return the content of `openspec/changes/{changeId}/design.md` + +#### Scenario: Archive resource + +- **WHEN** fetching `openspec://archive` +- **THEN** return a markdown list of archived changes sorted by date (newest first) + +### Requirement: MCP Tools + +The MCP server SHALL expose 6 tools that map to CLI commands and provide structured JSON responses. + +#### Scenario: Init tool + +- **WHEN** invoking the `init` tool +- **THEN** create the OpenSpec directory structure +- **AND** return JSON with success status, created files, and next steps +- **AND** return an error if OpenSpec is already initialized + +#### Scenario: List tool for changes + +- **WHEN** invoking the `list` tool with `specs=false` +- **THEN** return JSON with change list including id, file presence, and optional progress +- **AND** exclude archived changes + +#### Scenario: List tool for specs + +- **WHEN** invoking the `list` tool with `specs=true` +- **THEN** return JSON with spec list including capability names and optional summaries + +#### Scenario: Show tool for change + +- **WHEN** invoking the `show` tool with `type='change'` +- **THEN** return JSON with change details including proposal, tasks, design, and spec deltas +- **AND** include task progress calculation + +#### Scenario: Show tool for spec + +- **WHEN** invoking the `show` tool with `type='spec'` +- **THEN** return JSON with spec content + +#### Scenario: Validate tool + +- **WHEN** invoking the `validate` tool +- **THEN** check for required files and sections +- **AND** return JSON with valid status, errors, and warnings +- **AND** support `strict` mode that fails on warnings + +#### Scenario: Archive tool + +- **WHEN** invoking the `archive` tool +- **THEN** check task completion status +- **AND** support dry-run mode for preview +- **AND** merge spec deltas when `updateSpecs=true` +- **AND** move change to archive with date prefix +- **AND** support `force` mode to bypass task completion check + +#### Scenario: Update project context tool + +- **WHEN** invoking the `update_project_context` tool +- **THEN** write content to `openspec/project.md` +- **AND** support `merge` mode to append instead of replace +- **AND** support `section` parameter to update specific sections + +### Requirement: MCP Prompts + +The MCP server SHALL expose 3 guided workflow prompts. + +#### Scenario: Propose prompt + +- **WHEN** using the `openspec-propose` prompt +- **THEN** return a message instructing the AI to: + - Read required resources (instructions, project, specs, changes) + - Analyze the request for conflicts + - Create the proposal directory structure + - Validate the proposal + +#### Scenario: Apply prompt + +- **WHEN** using the `openspec-apply` prompt with a `changeId` +- **THEN** return a message instructing the AI to: + - Read the change resources + - Implement tasks sequentially + - Update task checkboxes as work completes + - Validate continuously + +#### Scenario: Archive prompt + +- **WHEN** using the `openspec-archive` prompt with a `changeId` +- **THEN** return a message instructing the AI to: + - Verify all tasks are complete + - Run validation + - Preview the archive operation + - Execute the archive + - Verify post-archive specs + +### Requirement: Path Resolution + +The MCP server SHALL support flexible path resolution for OpenSpec directories. + +#### Scenario: Default path resolution + +- **WHEN** no environment variables are set +- **THEN** use the current working directory as the project root +- **AND** look for OpenSpec in `{cwd}/openspec/` + +#### Scenario: Fixed root path resolution + +- **WHEN** `OPENSPEC_ROOT` is set and `OPENSPEC_AUTO_PROJECT_ROOT` is not `true` +- **THEN** use `OPENSPEC_ROOT` as the specs root +- **AND** look for OpenSpec in `{OPENSPEC_ROOT}/openspec/` + +#### Scenario: Auto project root path resolution + +- **WHEN** `OPENSPEC_ROOT` is set and `OPENSPEC_AUTO_PROJECT_ROOT` is `true` +- **THEN** derive a relative path from the home directory to the current project +- **AND** use `{OPENSPEC_ROOT}/{relative-path}/openspec/` as the specs location +- **AND** create the directory if it does not exist + +### Requirement: Server Configuration + +The MCP server SHALL provide standard MCP server metadata. + +#### Scenario: Server identification + +- **WHEN** an MCP client connects +- **THEN** identify the server as `openspec` with version matching the CLI version + +#### Scenario: Stdio transport + +- **WHEN** the server starts +- **THEN** use stdio transport for MCP communication +- **AND** output all logs and debug messages to stderr + diff --git a/openspec/changes/archive/2025-12-21-add-cli-mcp/tasks.md b/openspec/changes/archive/2025-12-21-add-cli-mcp/tasks.md new file mode 100644 index 00000000..dda91bf7 --- /dev/null +++ b/openspec/changes/archive/2025-12-21-add-cli-mcp/tasks.md @@ -0,0 +1,46 @@ +## 1. Setup and Dependencies + +- [x] 1.1 Add `@modelcontextprotocol/sdk` and `zod` dependencies to package.json +- [x] 1.2 Create `src/mcp/` directory structure with index.ts, server.ts, and subdirectories + +## 2. Core Infrastructure + +- [x] 2.1 Implement path resolver utility (`src/mcp/utils/path-resolver.ts`) with OPENSPEC_ROOT support +- [x] 2.2 Implement context loader utility (`src/mcp/utils/context-loader.ts`) +- [x] 2.3 Implement MCP server initialization and transport (`src/mcp/server.ts`) + +## 3. MCP Resources + +- [x] 3.1 Implement instructions resource (`openspec://instructions`) +- [x] 3.2 Implement project resource (`openspec://project`) +- [x] 3.3 Implement specs list and individual spec resources +- [x] 3.4 Implement changes list and individual change resources +- [x] 3.5 Implement archive resource + +## 4. MCP Tools + +- [x] 4.1 Implement init tool (maps to `openspec init`) +- [x] 4.2 Implement list tool (maps to `openspec list`) +- [x] 4.3 Implement show tool (maps to `openspec show`) +- [x] 4.4 Implement validate tool (maps to `openspec validate`) +- [x] 4.5 Implement archive tool (maps to `openspec archive`) +- [x] 4.6 Implement update_project_context tool (new) + +## 5. MCP Prompts + +- [x] 5.1 Implement openspec-propose prompt +- [x] 5.2 Implement openspec-apply prompt +- [x] 5.3 Implement openspec-archive prompt + +## 6. CLI Integration + +- [x] 6.1 Create `src/commands/mcp.ts` Commander.js subcommand +- [x] 6.2 Register mcp command in `src/cli/index.ts` +- [x] 6.3 Add --debug flag for stderr logging + +## 7. Testing and Documentation + +- [x] 7.1 Add unit tests for path resolver +- [x] 7.2 Add integration tests for MCP server +- [x] 7.3 Update README with MCP configuration examples + diff --git a/openspec/specs/cli-mcp/spec.md b/openspec/specs/cli-mcp/spec.md new file mode 100644 index 00000000..f7876e03 --- /dev/null +++ b/openspec/specs/cli-mcp/spec.md @@ -0,0 +1,215 @@ +# cli-mcp Specification + +## Purpose +TBD - created by archiving change add-cli-mcp. Update Purpose after archive. +## Requirements +### Requirement: MCP Server Subcommand + +The CLI SHALL provide an `openspec mcp` subcommand that starts a stdio-based MCP (Model Context Protocol) server for AI agent integration. + +#### Scenario: Starting the MCP server + +- **WHEN** executing `openspec mcp` +- **THEN** start a stdio-based MCP server +- **AND** register all resources, tools, and prompts +- **AND** output startup confirmation to stderr: `[openspec-mcp] Server started on stdio` + +#### Scenario: Debug mode + +- **WHEN** executing `openspec mcp --debug` +- **THEN** enable debug logging to stderr +- **AND** output path configuration details + +#### Scenario: Graceful shutdown + +- **WHEN** receiving SIGINT signal +- **THEN** close the MCP server gracefully +- **AND** exit with code 0 + +### Requirement: MCP Resources + +The MCP server SHALL expose 10 read-only resources for accessing OpenSpec data. + +#### Scenario: Instructions resource + +- **WHEN** fetching `openspec://instructions` +- **THEN** return the content of `openspec/AGENTS.md` +- **AND** return a default template if the file does not exist + +#### Scenario: Project resource + +- **WHEN** fetching `openspec://project` +- **THEN** return the content of `openspec/project.md` +- **AND** return an empty template if the file does not exist + +#### Scenario: Specs list resource + +- **WHEN** fetching `openspec://specs` +- **THEN** return a markdown list of all specification directories in `openspec/specs/` +- **AND** include links to individual spec resources + +#### Scenario: Individual spec resource + +- **WHEN** fetching `openspec://specs/{capability}` +- **THEN** return the content of `openspec/specs/{capability}/spec.md` +- **AND** return a "not found" message if the spec does not exist + +#### Scenario: Changes list resource + +- **WHEN** fetching `openspec://changes` +- **THEN** return a markdown list of all active change directories +- **AND** exclude the `archive/` subdirectory +- **AND** include links to individual change resources + +#### Scenario: Individual change resource + +- **WHEN** fetching `openspec://changes/{changeId}` +- **THEN** return all available files (proposal.md, tasks.md, design.md) as multiple content items +- **AND** return a "not found" message if the change does not exist + +#### Scenario: Change proposal resource + +- **WHEN** fetching `openspec://changes/{changeId}/proposal` +- **THEN** return the content of `openspec/changes/{changeId}/proposal.md` + +#### Scenario: Change tasks resource + +- **WHEN** fetching `openspec://changes/{changeId}/tasks` +- **THEN** return the content of `openspec/changes/{changeId}/tasks.md` + +#### Scenario: Change design resource + +- **WHEN** fetching `openspec://changes/{changeId}/design` +- **THEN** return the content of `openspec/changes/{changeId}/design.md` + +#### Scenario: Archive resource + +- **WHEN** fetching `openspec://archive` +- **THEN** return a markdown list of archived changes sorted by date (newest first) + +### Requirement: MCP Tools + +The MCP server SHALL expose 6 tools that map to CLI commands and provide structured JSON responses. + +#### Scenario: Init tool + +- **WHEN** invoking the `init` tool +- **THEN** create the OpenSpec directory structure +- **AND** return JSON with success status, created files, and next steps +- **AND** return an error if OpenSpec is already initialized + +#### Scenario: List tool for changes + +- **WHEN** invoking the `list` tool with `specs=false` +- **THEN** return JSON with change list including id, file presence, and optional progress +- **AND** exclude archived changes + +#### Scenario: List tool for specs + +- **WHEN** invoking the `list` tool with `specs=true` +- **THEN** return JSON with spec list including capability names and optional summaries + +#### Scenario: Show tool for change + +- **WHEN** invoking the `show` tool with `type='change'` +- **THEN** return JSON with change details including proposal, tasks, design, and spec deltas +- **AND** include task progress calculation + +#### Scenario: Show tool for spec + +- **WHEN** invoking the `show` tool with `type='spec'` +- **THEN** return JSON with spec content + +#### Scenario: Validate tool + +- **WHEN** invoking the `validate` tool +- **THEN** check for required files and sections +- **AND** return JSON with valid status, errors, and warnings +- **AND** support `strict` mode that fails on warnings + +#### Scenario: Archive tool + +- **WHEN** invoking the `archive` tool +- **THEN** check task completion status +- **AND** support dry-run mode for preview +- **AND** merge spec deltas when `updateSpecs=true` +- **AND** move change to archive with date prefix +- **AND** support `force` mode to bypass task completion check + +#### Scenario: Update project context tool + +- **WHEN** invoking the `update_project_context` tool +- **THEN** write content to `openspec/project.md` +- **AND** support `merge` mode to append instead of replace +- **AND** support `section` parameter to update specific sections + +### Requirement: MCP Prompts + +The MCP server SHALL expose 3 guided workflow prompts. + +#### Scenario: Propose prompt + +- **WHEN** using the `openspec-propose` prompt +- **THEN** return a message instructing the AI to: + - Read required resources (instructions, project, specs, changes) + - Analyze the request for conflicts + - Create the proposal directory structure + - Validate the proposal + +#### Scenario: Apply prompt + +- **WHEN** using the `openspec-apply` prompt with a `changeId` +- **THEN** return a message instructing the AI to: + - Read the change resources + - Implement tasks sequentially + - Update task checkboxes as work completes + - Validate continuously + +#### Scenario: Archive prompt + +- **WHEN** using the `openspec-archive` prompt with a `changeId` +- **THEN** return a message instructing the AI to: + - Verify all tasks are complete + - Run validation + - Preview the archive operation + - Execute the archive + - Verify post-archive specs + +### Requirement: Path Resolution + +The MCP server SHALL support flexible path resolution for OpenSpec directories. + +#### Scenario: Default path resolution + +- **WHEN** no environment variables are set +- **THEN** use the current working directory as the project root +- **AND** look for OpenSpec in `{cwd}/openspec/` + +#### Scenario: Fixed root path resolution + +- **WHEN** `OPENSPEC_ROOT` is set and `OPENSPEC_AUTO_PROJECT_ROOT` is not `true` +- **THEN** use `OPENSPEC_ROOT` as the specs root +- **AND** look for OpenSpec in `{OPENSPEC_ROOT}/openspec/` + +#### Scenario: Auto project root path resolution + +- **WHEN** `OPENSPEC_ROOT` is set and `OPENSPEC_AUTO_PROJECT_ROOT` is `true` +- **THEN** derive a relative path from the home directory to the current project +- **AND** use `{OPENSPEC_ROOT}/{relative-path}/openspec/` as the specs location +- **AND** create the directory if it does not exist + +### Requirement: Server Configuration + +The MCP server SHALL provide standard MCP server metadata. + +#### Scenario: Server identification + +- **WHEN** an MCP client connects +- **THEN** identify the server as `openspec` with version matching the CLI version + +#### Scenario: Stdio transport + +- **WHEN** the server starts +- **THEN** use stdio transport for MCP communication +- **AND** output all logs and debug messages to stderr + diff --git a/package.json b/package.json index 486873d5..e983ed5c 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build": "node build.js", "dev": "tsc --watch", "dev:cli": "pnpm build && node bin/openspec.js", + "dev:mcp": "node bin/openspec.js mcp", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -71,6 +72,7 @@ "dependencies": { "@inquirer/core": "^10.2.2", "@inquirer/prompts": "^7.8.0", + "@modelcontextprotocol/sdk": "^1.25.1", "chalk": "^5.5.0", "commander": "^14.0.0", "ora": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6661eed5..93f9e5c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@inquirer/prompts': specifier: ^7.8.0 version: 7.8.0(@types/node@24.2.0) + '@modelcontextprotocol/sdk': + specifier: ^1.25.1 + version: 1.25.1(hono@4.11.1)(zod@4.0.17) chalk: specifier: ^5.5.0 version: 5.5.0 @@ -304,6 +307,12 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hono/node-server@1.19.7': + resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -463,6 +472,16 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/sdk@1.25.1': + resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -689,6 +708,10 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -699,9 +722,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -743,6 +777,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + body-parser@2.2.1: + resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -753,10 +791,22 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -813,6 +863,26 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -826,6 +896,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -833,6 +912,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -841,24 +924,50 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.25.8: resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -913,10 +1022,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.2.2: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: + resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -937,6 +1068,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -968,6 +1102,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -983,6 +1121,14 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -996,10 +1142,21 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-east-asian-width@1.3.0: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1016,6 +1173,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1023,6 +1184,22 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.11.1: + resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-id@4.1.1: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true @@ -1035,6 +1212,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.1: + resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1051,6 +1232,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1071,6 +1259,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -1090,6 +1281,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -1107,6 +1301,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1144,6 +1344,18 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1152,6 +1364,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -1186,6 +1406,25 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -1240,6 +1479,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1248,6 +1491,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -1274,6 +1520,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1287,20 +1537,40 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1322,6 +1592,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1333,6 +1607,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1341,6 +1626,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1369,6 +1670,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -1445,6 +1750,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1463,6 +1772,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript-eslint@8.50.1: resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1482,9 +1795,17 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1576,6 +1897,9 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1584,6 +1908,11 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod-to-json-schema@3.25.0: + resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==} + peerDependencies: + zod: ^3.25 || ^4 + zod@4.0.17: resolution: {integrity: sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==} @@ -1859,6 +2188,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hono/node-server@1.19.7(hono@4.11.1)': + dependencies: + hono: 4.11.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2013,6 +2346,28 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@4.0.17)': + dependencies: + '@hono/node-server': 1.19.7(hono@4.11.1) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 7.5.1(express@5.2.1) + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.0.17 + zod-to-json-schema: 3.25.0(zod@4.0.17) + transitivePeerDependencies: + - hono + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2247,12 +2602,21 @@ snapshots: loupe: 3.2.0 tinyrainbow: 2.0.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -2260,6 +2624,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -2290,6 +2661,20 @@ snapshots: dependencies: is-windows: 1.0.2 + body-parser@2.2.1: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.1 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2303,8 +2688,20 @@ snapshots: dependencies: fill-range: 7.1.1 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chai@5.2.1: @@ -2348,6 +2745,19 @@ snapshots: concat-map@0.0.1: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2358,27 +2768,51 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + deep-eql@5.0.2: {} deep-is@0.1.4: {} + depd@2.0.0: {} + detect-indent@6.1.0: {} dir-glob@3.0.1: dependencies: path-type: 4.0.0 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.25.8: optionalDependencies: '@esbuild/aix-ppc64': 0.25.8 @@ -2408,6 +2842,8 @@ snapshots: '@esbuild/win32-ia32': 0.25.8 '@esbuild/win32-x64': 0.25.8 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -2482,8 +2918,53 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + expect-type@1.2.2: {} + express-rate-limit@7.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.1 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.1 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extendable-error@0.1.7: {} external-editor@3.1.0: @@ -2506,6 +2987,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -2528,6 +3011,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.1 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -2545,6 +3039,10 @@ snapshots: flatted@3.3.3: {} + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -2560,8 +3058,28 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + get-east-asian-width@1.3.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2581,10 +3099,28 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.11.1: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-id@4.1.1: {} iconv-lite@0.4.24: @@ -2595,6 +3131,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.1: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -2606,6 +3146,10 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2618,6 +3162,8 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 @@ -2630,6 +3176,8 @@ snapshots: isexe@2.0.0: {} + jose@6.1.3: {} + js-tokens@9.0.1: {} js-yaml@3.14.1: @@ -2645,6 +3193,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@4.0.0: @@ -2683,6 +3235,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.4 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2690,6 +3248,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-function@5.0.1: {} minimatch@3.1.2: @@ -2712,6 +3276,20 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -2773,10 +3351,14 @@ snapshots: dependencies: callsites: 3.1.0 + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} pathe@2.0.3: {} @@ -2791,6 +3373,8 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2801,12 +3385,30 @@ snapshots: prettier@2.8.8: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.1 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -2814,6 +3416,8 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -2851,6 +3455,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.46.2 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.1 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -2859,12 +3473,67 @@ snapshots: semver@7.7.2: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -2888,6 +3557,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.9.0: {} stdin-discarder@0.2.2: {} @@ -2954,6 +3625,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + totalist@3.0.1: {} ts-api-utils@2.1.0(typescript@5.9.3): @@ -2966,6 +3639,12 @@ snapshots: type-fest@0.21.3: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.50.1(eslint@9.39.2)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3) @@ -2983,10 +3662,14 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 + vary@1.1.2: {} + vite-node@3.2.4(@types/node@24.2.0): dependencies: cac: 6.7.14 @@ -3079,8 +3762,14 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + zod-to-json-schema@3.25.0(zod@4.0.17): + dependencies: + zod: 4.0.17 + zod@4.0.17: {} diff --git a/src/cli/index.ts b/src/cli/index.ts index e8cb2f53..1c01fcde 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,7 @@ import { ChangeCommand } from '../commands/change.js'; import { ValidateCommand } from '../commands/validate.js'; import { ShowCommand } from '../commands/show.js'; import { CompletionCommand } from '../commands/completion.js'; +import { createMcpCommand } from '../commands/mcp.js'; import { registerConfigCommand } from '../commands/config.js'; const program = new Command(); @@ -203,6 +204,8 @@ program registerSpecCommand(program); registerConfigCommand(program); +program.addCommand(createMcpCommand()); + // Top-level validate command program .command('validate [item-name]') diff --git a/src/commands/change.ts b/src/commands/change.ts index 051b4697..82e365f4 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -25,8 +25,9 @@ export class ChangeCommand { * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas * Note: --requirements-only is deprecated alias for --deltas-only */ - async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; targetPath?: string }): Promise { + const targetPath = options?.targetPath ?? process.cwd(); + const changesPath = path.join(targetPath, 'openspec', 'changes'); if (!changeName) { const canPrompt = isInteractive(options); @@ -94,8 +95,9 @@ export class ChangeCommand { * - Text default: IDs only; --long prints minimal details (title, counts) * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id */ - async list(options?: { json?: boolean; long?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + async list(options?: { json?: boolean; long?: boolean; targetPath?: string }): Promise { + const targetPath = options?.targetPath ?? process.cwd(); + const changesPath = path.join(targetPath, 'openspec', 'changes'); const changes = await this.getActiveChanges(changesPath); @@ -182,12 +184,13 @@ export class ChangeCommand { } } - async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise { - const changesPath = path.join(process.cwd(), 'openspec', 'changes'); + async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean; targetPath?: string }): Promise { + const targetPath = options?.targetPath ?? process.cwd(); + const changesPath = path.join(targetPath, 'openspec', 'changes'); if (!changeName) { const canPrompt = isInteractive(options); - const changes = await getActiveChangeIds(); + const changes = await getActiveChangeIds(targetPath); if (canPrompt && changes.length > 0) { const { select } = await import('@inquirer/prompts'); const selected = await select({ diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 00000000..44a92cf5 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,13 @@ +import { Command } from 'commander'; +import { startMcpServer } from '../mcp/index.js'; + +export function createMcpCommand(): Command { + return new Command('mcp') + .description('Start stdio-based MCP server for AI agent integration') + .option('--debug', 'Enable debug logging to stderr') + .action(async (options) => { + await startMcpServer({ + debug: options.debug ?? false, + }); + }); +} diff --git a/src/commands/spec.ts b/src/commands/spec.ts index d28052f1..77f60fbc 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -16,6 +16,7 @@ interface ShowOptions { scenarios?: boolean; // --no-scenarios sets this to false (JSON only) requirement?: string; // JSON only noInteractive?: boolean; + targetPath?: string; } function parseSpecFromFile(specPath: string, specId: string): Spec { @@ -65,12 +66,13 @@ function printSpecTextRaw(specPath: string): void { } export class SpecCommand { - private SPECS_DIR = 'openspec/specs'; - async show(specId?: string, options: ShowOptions = {}): Promise { + const targetPath = options.targetPath ?? process.cwd(); + const specsDir = join(targetPath, 'openspec', 'specs'); + if (!specId) { const canPrompt = isInteractive(options); - const specIds = await getSpecIds(); + const specIds = await getSpecIds(targetPath); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ @@ -82,9 +84,10 @@ export class SpecCommand { } } - const specPath = join(this.SPECS_DIR, specId, 'spec.md'); + const specPath = join(specsDir, specId, 'spec.md'); if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); + const relativePath = join('openspec', 'specs', specId, 'spec.md'); + throw new Error(`Spec '${specId}' not found at ${relativePath}`); } if (options.json) { @@ -141,17 +144,19 @@ export function registerSpecCommand(rootProgram: typeof program) { .description('List all available specifications') .option('--json', 'Output as JSON') .option('--long', 'Show id and title with counts') - .action((options: { json?: boolean; long?: boolean }) => { + .action((options: { json?: boolean; long?: boolean; targetPath?: string }) => { try { - if (!existsSync(SPECS_DIR)) { + const targetPath = options.targetPath ?? process.cwd(); + const specsDir = join(targetPath, 'openspec', 'specs'); + if (!existsSync(specsDir)) { console.log('No items found'); return; } - const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) + const specs = readdirSync(specsDir, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => { - const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); + const specPath = join(specsDir, dirent.name, 'spec.md'); if (existsSync(specPath)) { try { const spec = parseSpecFromFile(specPath, dirent.name); @@ -201,11 +206,14 @@ export function registerSpecCommand(rootProgram: typeof program) { .option('--strict', 'Enable strict validation mode') .option('--json', 'Output validation report as JSON') .option('--no-interactive', 'Disable interactive prompts') - .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { + .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean; targetPath?: string }) => { try { + const targetPath = options.targetPath ?? process.cwd(); + const specsDir = join(targetPath, 'openspec', 'specs'); + if (!specId) { const canPrompt = isInteractive(options); - const specIds = await getSpecIds(); + const specIds = await getSpecIds(targetPath); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ @@ -217,10 +225,10 @@ export function registerSpecCommand(rootProgram: typeof program) { } } - const specPath = join(SPECS_DIR, specId, 'spec.md'); + const specPath = join(specsDir, specId, 'spec.md'); if (!existsSync(specPath)) { - throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); + throw new Error(`Spec '${specId}' not found at ${join('openspec', 'specs', specId, 'spec.md')}`); } const validator = new Validator(options.strict); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 9e59a4d4..c28ca008 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -17,6 +17,7 @@ interface ExecuteOptions { noInteractive?: boolean; interactive?: boolean; // Commander sets this to false when --no-interactive is used concurrency?: string; + targetPath?: string; } interface BulkItemResult { @@ -31,19 +32,21 @@ export class ValidateCommand { async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise { const interactive = isInteractive(options); + const targetPath = options.targetPath ?? process.cwd(); + // Handle bulk flags first if (options.all || options.changes || options.specs) { await this.runBulkValidation({ changes: !!options.all || !!options.changes, specs: !!options.all || !!options.specs, - }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) }); + }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, targetPath, noInteractive: resolveNoInteractive(options) }); return; } // No item and no flags if (!itemName) { if (interactive) { - await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); + await this.runInteractiveSelector({ strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, targetPath }); return; } this.printNonInteractiveHint(); @@ -53,7 +56,7 @@ export class ValidateCommand { // Direct item validation with type detection or override const typeOverride = this.normalizeType(options.type); - await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); + await this.validateDirectItem(itemName, { typeOverride, strict: !!options.strict, json: !!options.json, targetPath }); } private normalizeType(value?: string): ItemType | undefined { @@ -63,7 +66,7 @@ export class ValidateCommand { return undefined; } - private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { + private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string; targetPath: string }): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ message: 'What would you like to validate?', @@ -80,7 +83,7 @@ export class ValidateCommand { if (choice === 'specs') return this.runBulkValidation({ changes: false, specs: true }, opts); // one - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + const [changes, specs] = await Promise.all([getActiveChangeIds(opts.targetPath), getSpecIds(opts.targetPath)]); const items: { name: string; value: { type: ItemType; id: string } }[] = []; items.push(...changes.map(id => ({ name: `change/${id}`, value: { type: 'change' as const, id } }))); items.push(...specs.map(id => ({ name: `spec/${id}`, value: { type: 'spec' as const, id } }))); @@ -90,7 +93,7 @@ export class ValidateCommand { return; } const picked = await select<{ type: ItemType; id: string }>({ message: 'Pick an item', choices: items }); - await this.validateByType(picked.type, picked.id, opts); + await this.validateByType(picked.type, picked.id, { strict: opts.strict, json: opts.json, targetPath: opts.targetPath }); } private printNonInteractiveHint(): void { @@ -102,8 +105,8 @@ export class ValidateCommand { console.error('Or run in an interactive terminal.'); } - private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { - const [changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]); + private async validateDirectItem(itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean; targetPath: string }): Promise { + const [changes, specs] = await Promise.all([getActiveChangeIds(opts.targetPath), getSpecIds(opts.targetPath)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -124,13 +127,13 @@ export class ValidateCommand { return; } - await this.validateByType(type, itemName, opts); + await this.validateByType(type, itemName, { strict: opts.strict, json: opts.json, targetPath: opts.targetPath }); } - private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { + private async validateByType(type: ItemType, id: string, opts: { strict: boolean; json: boolean; targetPath: string }): Promise { const validator = new Validator(opts.strict); if (type === 'change') { - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = path.join(opts.targetPath, 'openspec', 'changes', id); const start = Date.now(); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; @@ -139,7 +142,7 @@ export class ValidateCommand { process.exitCode = report.valid ? 0 : 1; return; } - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(opts.targetPath, 'openspec', 'specs', id, 'spec.md'); const start = Date.now(); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; @@ -181,11 +184,11 @@ export class ValidateCommand { bullets.forEach(b => console.error(` ${b}`)); } - private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { + private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; targetPath: string; noInteractive?: boolean }): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ - scope.changes ? getActiveChangeIds() : Promise.resolve([]), - scope.specs ? getSpecIds() : Promise.resolve([]), + scope.changes ? getActiveChangeIds(opts.targetPath) : Promise.resolve([]), + scope.specs ? getSpecIds(opts.targetPath) : Promise.resolve([]), ]); const DEFAULT_CONCURRENCY = 6; @@ -197,7 +200,7 @@ export class ValidateCommand { for (const id of changeIds) { queue.push(async () => { const start = Date.now(); - const changeDir = path.join(process.cwd(), 'openspec', 'changes', id); + const changeDir = path.join(opts.targetPath, 'openspec', 'changes', id); const report = await validator.validateChangeDeltaSpecs(changeDir); const durationMs = Date.now() - start; return { id, type: 'change' as const, valid: report.valid, issues: report.issues, durationMs }; @@ -206,7 +209,7 @@ export class ValidateCommand { for (const id of specIds) { queue.push(async () => { const start = Date.now(); - const file = path.join(process.cwd(), 'openspec', 'specs', id, 'spec.md'); + const file = path.join(opts.targetPath, 'openspec', 'specs', id, 'spec.md'); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; return { id, type: 'spec' as const, valid: report.valid, issues: report.issues, durationMs }; diff --git a/src/core/archive.ts b/src/core/archive.ts index c9756024..81336738 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -20,9 +20,9 @@ interface SpecUpdate { export class ArchiveCommand { async execute( changeName?: string, - options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {} + options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean; targetPath?: string } = {} ): Promise { - const targetPath = '.'; + const targetPath = options.targetPath ?? '.'; const changesDir = path.join(targetPath, 'openspec', 'changes'); const archiveDir = path.join(changesDir, 'archive'); const mainSpecsDir = path.join(targetPath, 'openspec', 'specs'); diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 00000000..f14390eb --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,12 @@ +/** + * MCP (Model Context Protocol) Server module. + * + * This module exposes OpenSpec capabilities to AI coding assistants via + * the MCP protocol. It provides resources, tools, and prompts for + * spec-driven development workflows. + * + * @module mcp + */ + +export { startMcpServer } from './server.js'; +export type { McpServerOptions } from './server.js'; diff --git a/src/mcp/prompts/apply-prompt.ts b/src/mcp/prompts/apply-prompt.ts new file mode 100644 index 00000000..bf3bb419 --- /dev/null +++ b/src/mcp/prompts/apply-prompt.ts @@ -0,0 +1,49 @@ +/** + * MCP prompt: openspec-apply + * + * Guided workflow for implementing an approved OpenSpec change. + * Equivalent to /openspec:apply slash command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getMcpApplyContent } from './mcp-prompt-templates.js'; + +const InputSchema = z.object({ + changeId: z.string().describe('The change identifier to implement'), +}); + +/** + * Register the openspec-apply prompt with the MCP server. + */ +export function registerApplyPrompt( + server: McpServer, + _pathConfig: PathConfig +): void { + server.registerPrompt( + 'openspec-apply', + { + title: 'openspec-apply', + description: + 'Guided workflow for implementing an approved OpenSpec change. Equivalent to /openspec:apply slash command.', + argsSchema: InputSchema.shape, + }, + (args) => { + const parsed = InputSchema.parse(args); + const content = getMcpApplyContent(parsed.changeId); + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: content, + }, + }, + ], + }; + } + ); +} diff --git a/src/mcp/prompts/archive-prompt.ts b/src/mcp/prompts/archive-prompt.ts new file mode 100644 index 00000000..51bb2701 --- /dev/null +++ b/src/mcp/prompts/archive-prompt.ts @@ -0,0 +1,49 @@ +/** + * MCP prompt: openspec-archive + * + * Guided workflow for archiving a completed change. + * Equivalent to /openspec:archive slash command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getMcpArchiveContent } from './mcp-prompt-templates.js'; + +const InputSchema = z.object({ + changeId: z.string().optional().describe('The change identifier to archive'), +}); + +/** + * Register the openspec-archive prompt with the MCP server. + */ +export function registerArchivePrompt( + server: McpServer, + _pathConfig: PathConfig +): void { + server.registerPrompt( + 'openspec-archive', + { + title: 'openspec-archive', + description: + 'Guided workflow for archiving a completed change. Equivalent to /openspec:archive slash command.', + argsSchema: InputSchema.shape, + }, + (args) => { + const parsed = InputSchema.parse(args); + const content = getMcpArchiveContent(parsed.changeId); + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: content, + }, + }, + ], + }; + } + ); +} diff --git a/src/mcp/prompts/index.ts b/src/mcp/prompts/index.ts new file mode 100644 index 00000000..15a45094 --- /dev/null +++ b/src/mcp/prompts/index.ts @@ -0,0 +1,26 @@ +/** + * MCP Prompts registration. + * + * Prompts provide guided workflows for AI assistants: + * - openspec-propose: Create change proposal workflow + * - openspec-apply: Implement change workflow + * - openspec-archive: Archive completed change workflow + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { registerProposePrompt } from './propose-prompt.js'; +import { registerApplyPrompt } from './apply-prompt.js'; +import { registerArchivePrompt } from './archive-prompt.js'; + +/** + * Register all OpenSpec prompts with the MCP server. + */ +export function registerAllPrompts( + server: McpServer, + pathConfig: PathConfig +): void { + registerProposePrompt(server, pathConfig); + registerApplyPrompt(server, pathConfig); + registerArchivePrompt(server, pathConfig); +} diff --git a/src/mcp/prompts/mcp-prompt-templates.ts b/src/mcp/prompts/mcp-prompt-templates.ts new file mode 100644 index 00000000..7f000cc9 --- /dev/null +++ b/src/mcp/prompts/mcp-prompt-templates.ts @@ -0,0 +1,95 @@ +/** + * MCP Prompt Templates + * + * Provides MCP-adapted prompt content for OpenSpec workflows. + * These templates reuse slash-command-templates and add an MCP preamble + * that instructs agents to use MCP resources and tools instead of CLI commands. + */ + +import { slashCommandBodies } from '../../core/templates/slash-command-templates.js'; + +const mcpPreamble = ` +You are using OpenSpec through the MCP (Model Context Protocol) interface. +The instructions below reference CLI commands and filesystem paths for compatibility, +but you MUST use MCP resources and tools instead. Follow the override rules below. + + + +These overrides are MANDATORY. When the instructions mention CLI commands or file paths, +translate them to MCP equivalents as specified below. + + +| Filesystem Reference | MCP Resource | +|---------------------|--------------| +| openspec/AGENTS.md | openspec://instructions | +| openspec/project.md | openspec://project | +| changes/<id>/proposal.md | openspec://changes/{id}/proposal | +| changes/<id>/tasks.md | openspec://changes/{id}/tasks | +| changes/<id>/design.md | openspec://changes/{id}/design | +| openspec/specs/<capability>/spec.md | openspec://specs/{capability} | +| openspec/specs (directory) | openspec://specs | +| openspec/changes (directory) | openspec://changes | +| changes/<id>/specs (directory) | openspec://changes/{id}/specs | +| changes/<id>/specs/<capability>/spec.md | openspec://changes/{id}/specs/{capability} | + + + +| CLI Command | MCP Tool | +|------------|----------| +| openspec list | list tool with specs=false | +| openspec list --specs | list tool with specs=true | +| openspec show <id> | show tool with name=<id> and type='change' | +| openspec show <spec> --type spec | show tool with name=<spec> and type='spec' | +| openspec validate <id> --strict | validate tool with name=<id>, type='change', strict=true | +| openspec validate --strict | validate tool with type='all', strict=true | +| openspec archive <id> --yes | archive tool with name=<id>, updateSpecs=true, dryRun=false | +| openspec archive <id> --skip-specs | archive tool with name=<id>, updateSpecs=false, dryRun=false | +| openspec update | update_project_context tool | +| Write proposal.md | edit tool with changeId, resourceType='proposal', content | +| Write tasks.md | edit tool with changeId, resourceType='tasks', content | +| Write design.md | edit tool with changeId, resourceType='design', content | +| Write spec delta | edit tool with changeId, resourceType='spec', capability, content | + + + +When instructions mention shell commands for exploring code or specs: +- Instead of rg/grep on openspec/specs, read openspec://specs resource +- Instead of ls on openspec directories, use the list tool +- Use your AI host's native file exploration tools for codebase inspection + + + +When instructions reference openspec/AGENTS.md for conventions, read openspec://instructions instead. + + + +--- + +`; + +/** + * Get the MCP-adapted proposal prompt content with preamble. + */ +export function getMcpProposeContent(description?: string): string { + const header = description + ? `## Your Task\n\nCreate a new OpenSpec change proposal for: "${description}"\n\n` + : '## Your Task\n\nCreate a new OpenSpec change proposal.\n\n'; + return `${mcpPreamble}${header}${slashCommandBodies.proposal}`; +} + +/** + * Get the MCP-adapted apply prompt content with preamble. + */ +export function getMcpApplyContent(changeId: string): string { + return `${mcpPreamble}## Implement Change: ${changeId}\n\n${slashCommandBodies.apply}`; +} + +/** + * Get the MCP-adapted archive prompt content with preamble. + */ +export function getMcpArchiveContent(changeId?: string): string { + const header = changeId + ? `## Archive Change: ${changeId}\n\n` + : '## Archive a Completed Change\n\n'; + return `${mcpPreamble}${header}${slashCommandBodies.archive}`; +} diff --git a/src/mcp/prompts/propose-prompt.ts b/src/mcp/prompts/propose-prompt.ts new file mode 100644 index 00000000..fed23196 --- /dev/null +++ b/src/mcp/prompts/propose-prompt.ts @@ -0,0 +1,49 @@ +/** + * MCP prompt: openspec-propose + * + * Guided workflow for creating a new OpenSpec change proposal. + * Equivalent to /openspec:proposal slash command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getMcpProposeContent } from './mcp-prompt-templates.js'; + +const InputSchema = z.object({ + description: z.string().optional().describe('What you want to build or change'), +}); + +/** + * Register the openspec-propose prompt with the MCP server. + */ +export function registerProposePrompt( + server: McpServer, + _pathConfig: PathConfig +): void { + server.registerPrompt( + 'openspec-propose', + { + title: 'openspec-propose', + description: + 'Guided workflow for creating a new OpenSpec change proposal. Equivalent to /openspec:proposal slash command.', + argsSchema: InputSchema.shape, + }, + (args) => { + const parsed = InputSchema.parse(args); + const content = getMcpProposeContent(parsed.description); + + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: content, + }, + }, + ], + }; + } + ); +} diff --git a/src/mcp/resources/archive.ts b/src/mcp/resources/archive.ts new file mode 100644 index 00000000..f205d3a7 --- /dev/null +++ b/src/mcp/resources/archive.ts @@ -0,0 +1,60 @@ +/** + * Archive resource registration. + * + * Exposes openspec://archive resource that returns a list of archived changes. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getArchiveDir } from '../utils/path-resolver.js'; + +/** + * Register the archive resource (openspec://archive). + * + * Returns a list of archived changes, sorted by name (newest first, assuming date prefix). + */ +export function registerArchiveResource( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerResource( + 'archive', + 'openspec://archive', + { + title: 'Archived Changes', + description: 'List of archived (completed) changes', + mimeType: 'text/markdown', + }, + async (uri) => { + const archiveDir = getArchiveDir(pathConfig); + + let archived: string[] = []; + try { + const entries = await fs.readdir(archiveDir, { withFileTypes: true }); + archived = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort() + .reverse(); // Newest first (assuming date prefix format) + } catch { + // Directory doesn't exist, return empty list + } + + const content = + archived.length > 0 + ? `# Archived Changes\n\n${archived.map((a) => `- ${a}`).join('\n')}` + : '# Archived Changes\n\nNo archived changes yet.'; + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); +} diff --git a/src/mcp/resources/changes.ts b/src/mcp/resources/changes.ts new file mode 100644 index 00000000..2f65f0c0 --- /dev/null +++ b/src/mcp/resources/changes.ts @@ -0,0 +1,295 @@ +/** + * Changes resources registration. + * + * Exposes resources for accessing change proposals: + * - openspec://changes - List of active changes + * - openspec://changes/{changeId} - Complete change details + * - openspec://changes/{changeId}/proposal - Proposal document + * - openspec://changes/{changeId}/tasks - Tasks checklist + * - openspec://changes/{changeId}/design - Design document + * - openspec://changes/{changeId}/specs - List of spec deltas + * - openspec://changes/{changeId}/specs/{capability} - Individual spec delta + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { + getChangesDir, + getChangePath, + getChangeProposalPath, + getChangeTasksPath, + getChangeDesignPath, + getChangeSpecsDir, + getChangeSpecDeltaPath, +} from '../utils/path-resolver.js'; + +/** + * Register changes resources. + */ +export function registerChangesResources( + server: McpServer, + pathConfig: PathConfig +): void { + // List all active changes + server.registerResource( + 'changes-list', + 'openspec://changes', + { + title: 'Active Changes', + description: 'List of all active change proposals', + mimeType: 'text/markdown', + }, + async (uri) => { + const changesDir = getChangesDir(pathConfig); + + let changes: string[] = []; + try { + const entries = await fs.readdir(changesDir, { withFileTypes: true }); + changes = entries + .filter((e) => e.isDirectory() && e.name !== 'archive') + .map((e) => e.name); + } catch { + // Directory doesn't exist, return empty list + } + + const content = + changes.length > 0 + ? `# Active Changes\n\n${changes + .map((c) => `- [${c}](openspec://changes/${c})`) + .join('\n')}` + : '# Active Changes\n\nNo active changes.'; + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); + + // Individual change - returns all files + server.registerResource( + 'change', + new ResourceTemplate('openspec://changes/{changeId}', { + list: undefined, + }), + { + title: 'Change Details', + description: 'Complete change details including proposal, tasks, and design', + mimeType: 'text/markdown', + }, + async (uri, { changeId }) => { + const [proposal, tasks, design] = await Promise.all([ + readFileIfExists(getChangeProposalPath(pathConfig, changeId as string)), + readFileIfExists(getChangeTasksPath(pathConfig, changeId as string)), + readFileIfExists(getChangeDesignPath(pathConfig, changeId as string)), + ]); + + const contents = []; + if (proposal) { + contents.push({ + uri: `openspec://changes/${changeId}/proposal`, + mimeType: 'text/markdown', + text: proposal, + }); + } + if (tasks) { + contents.push({ + uri: `openspec://changes/${changeId}/tasks`, + mimeType: 'text/markdown', + text: tasks, + }); + } + if (design) { + contents.push({ + uri: `openspec://changes/${changeId}/design`, + mimeType: 'text/markdown', + text: design, + }); + } + + if (contents.length === 0) { + contents.push({ + uri: uri.href, + mimeType: 'text/markdown', + text: `# ${changeId}\n\nChange not found.`, + }); + } + + return { contents }; + } + ); + + // Individual change files + server.registerResource( + 'change-proposal', + new ResourceTemplate('openspec://changes/{changeId}/proposal', { + list: undefined, + }), + { + title: 'Change Proposal', + description: 'Proposal document', + mimeType: 'text/markdown', + }, + async (uri, { changeId }) => { + const content = await readFileIfExists( + getChangeProposalPath(pathConfig, changeId as string) + ); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content || 'Not found', + }, + ], + }; + } + ); + + server.registerResource( + 'change-tasks', + new ResourceTemplate('openspec://changes/{changeId}/tasks', { + list: undefined, + }), + { + title: 'Change Tasks', + description: 'Tasks checklist', + mimeType: 'text/markdown', + }, + async (uri, { changeId }) => { + const content = await readFileIfExists( + getChangeTasksPath(pathConfig, changeId as string) + ); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content || 'Not found', + }, + ], + }; + } + ); + + server.registerResource( + 'change-design', + new ResourceTemplate('openspec://changes/{changeId}/design', { + list: undefined, + }), + { + title: 'Change Design', + description: 'Design document', + mimeType: 'text/markdown', + }, + async (uri, { changeId }) => { + const content = await readFileIfExists( + getChangeDesignPath(pathConfig, changeId as string) + ); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content || 'Not found', + }, + ], + }; + } + ); + + // Spec deltas list for a change + server.registerResource( + 'change-specs-list', + new ResourceTemplate('openspec://changes/{changeId}/specs', { + list: undefined, + }), + { + title: 'Change Spec Deltas', + description: 'List of spec deltas in a change proposal', + mimeType: 'text/markdown', + }, + async (uri, { changeId }) => { + const specsDir = getChangeSpecsDir(pathConfig, changeId as string); + + let capabilities: string[] = []; + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + capabilities = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + // Directory doesn't exist, return empty list + } + + const content = + capabilities.length > 0 + ? `# Spec Deltas for ${changeId}\n\n${capabilities + .map( + (c) => + `- [${c}](openspec://changes/${changeId}/specs/${c})` + ) + .join('\n')}` + : `# Spec Deltas for ${changeId}\n\nNo spec deltas defined.`; + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); + + // Individual spec delta for a change + server.registerResource( + 'change-spec-delta', + new ResourceTemplate('openspec://changes/{changeId}/specs/{capability}', { + list: undefined, + }), + { + title: 'Spec Delta', + description: 'Spec delta document for a specific capability', + mimeType: 'text/markdown', + }, + async (uri, { changeId, capability }) => { + const specPath = getChangeSpecDeltaPath( + pathConfig, + changeId as string, + capability as string + ); + + const content = await readFileIfExists(specPath); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content || `Spec delta for ${capability} not found.`, + }, + ], + }; + } + ); +} + +/** + * Helper to read a file if it exists, returning null if not found. + */ +async function readFileIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/mcp/resources/index.ts b/src/mcp/resources/index.ts new file mode 100644 index 00000000..3d5939d4 --- /dev/null +++ b/src/mcp/resources/index.ts @@ -0,0 +1,32 @@ +/** + * MCP Resources registration. + * + * Resources provide read-only access to OpenSpec data: + * - openspec://instructions (AGENTS.md) + * - openspec://project (project.md) + * - openspec://specs, openspec://specs/{capability} + * - openspec://changes, openspec://changes/{changeId} + * - openspec://archive + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { registerInstructionsResource } from './instructions.js'; +import { registerProjectResource } from './project.js'; +import { registerSpecsResources } from './specs.js'; +import { registerChangesResources } from './changes.js'; +import { registerArchiveResource } from './archive.js'; + +/** + * Register all OpenSpec resources with the MCP server. + */ +export function registerAllResources( + server: McpServer, + pathConfig: PathConfig +): void { + registerInstructionsResource(server, pathConfig); + registerProjectResource(server, pathConfig); + registerSpecsResources(server, pathConfig); + registerChangesResources(server, pathConfig); + registerArchiveResource(server, pathConfig); +} diff --git a/src/mcp/resources/instructions.ts b/src/mcp/resources/instructions.ts new file mode 100644 index 00000000..539ffda7 --- /dev/null +++ b/src/mcp/resources/instructions.ts @@ -0,0 +1,42 @@ +/** + * Instructions resource registration. + * + * Exposes openspec://instructions resource that returns AGENTS.md content. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { loadAgentsMarkdown } from '../utils/context-loader.js'; + +/** + * Register the instructions resource (openspec://instructions). + * + * Returns AGENTS.md content with fallback to default template if file doesn't exist. + */ +export function registerInstructionsResource( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerResource( + 'instructions', + 'openspec://instructions', + { + title: 'OpenSpec Instructions', + description: + 'OpenSpec workflow instructions from AGENTS.md. AI assistants should read this first to understand how to use OpenSpec tools and prompts.', + mimeType: 'text/markdown', + }, + async (uri) => { + const content = await loadAgentsMarkdown(pathConfig); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); +} diff --git a/src/mcp/resources/project.ts b/src/mcp/resources/project.ts new file mode 100644 index 00000000..f0812828 --- /dev/null +++ b/src/mcp/resources/project.ts @@ -0,0 +1,42 @@ +/** + * Project resource registration. + * + * Exposes openspec://project resource that returns project.md content. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { loadProjectMarkdown } from '../utils/context-loader.js'; + +/** + * Register the project resource (openspec://project). + * + * Returns project.md content with fallback to default template if file doesn't exist. + */ +export function registerProjectResource( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerResource( + 'project', + 'openspec://project', + { + title: 'Project Context', + description: + 'Project context including tech stack, conventions, and architectural patterns. Use update_project_context tool to modify.', + mimeType: 'text/markdown', + }, + async (uri) => { + const content = await loadProjectMarkdown(pathConfig); + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); +} diff --git a/src/mcp/resources/specs.ts b/src/mcp/resources/specs.ts new file mode 100644 index 00000000..774c3539 --- /dev/null +++ b/src/mcp/resources/specs.ts @@ -0,0 +1,93 @@ +/** + * Specs resources registration. + * + * Exposes openspec://specs (list) and openspec://specs/{capability} (individual) resources. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as fs from 'fs/promises'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getSpecsDir, getSpecPath } from '../utils/path-resolver.js'; + +/** + * Register specs resources: + * - openspec://specs - List of all specifications + * - openspec://specs/{capability} - Individual spec content + */ +export function registerSpecsResources( + server: McpServer, + pathConfig: PathConfig +): void { + // List all specs + server.registerResource( + 'specs-list', + 'openspec://specs', + { + title: 'Specifications List', + description: 'List of all specifications in openspec/specs/', + mimeType: 'text/markdown', + }, + async (uri) => { + const specsDir = getSpecsDir(pathConfig); + + let specs: string[] = []; + try { + const entries = await fs.readdir(specsDir, { withFileTypes: true }); + specs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + // Directory doesn't exist, return empty list + } + + const content = + specs.length > 0 + ? `# Specifications\n\n${specs + .map((s) => `- [${s}](openspec://specs/${s})`) + .join('\n')}` + : '# Specifications\n\nNo specifications found.'; + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); + + // Individual spec + server.registerResource( + 'spec', + new ResourceTemplate('openspec://specs/{capability}', { + list: undefined, + }), + { + title: 'Specification', + description: 'Specification document for a specific capability', + mimeType: 'text/markdown', + }, + async (uri, { capability }) => { + const specPath = getSpecPath(pathConfig, capability as string); + + let content: string; + try { + content = await fs.readFile(specPath, 'utf-8'); + } catch { + content = `# ${capability}\n\nSpecification not found.`; + } + + return { + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: content, + }, + ], + }; + } + ); +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 00000000..0495a2bb --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,66 @@ +/** + * MCP Server initialization and transport handling. + * + * This module creates and configures the MCP server instance, + * registers all resources/tools/prompts, and manages the stdio transport. + */ + +import { createRequire } from 'module'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { resolveOpenSpecPaths } from './utils/path-resolver.js'; +import { registerAllResources } from './resources/index.js'; +import { registerAllTools } from './tools/index.js'; +import { registerAllPrompts } from './prompts/index.js'; + +const require = createRequire(import.meta.url); +const { version } = require('../../package.json'); + +export interface McpServerOptions { + /** Enable debug logging to stderr */ + debug: boolean; +} + +/** + * Start the OpenSpec MCP server on stdio transport. + * + * The server exposes OpenSpec resources (specs, changes, project context), + * tools (init, list, show, validate, archive, update_project_context), + * and prompts (propose, apply, archive workflows). + */ +export async function startMcpServer(options: McpServerOptions): Promise { + const pathConfig = resolveOpenSpecPaths(); + + if (options.debug) { + console.error('[openspec-mcp] Path configuration:', JSON.stringify(pathConfig, null, 2)); + } + + const server = new McpServer({ + name: 'openspec', + version, + }); + + registerAllResources(server, pathConfig); + registerAllTools(server, pathConfig); + registerAllPrompts(server, pathConfig); + + const transport = new StdioServerTransport(); + + process.on('SIGINT', async () => { + await server.close(); + process.exit(0); + }); + + try { + if (options.debug) { + console.error('[openspec-mcp] Starting server on stdio...'); + } + await server.connect(transport); + if (options.debug) { + console.error('[openspec-mcp] Server started'); + } + } catch (error) { + console.error('[openspec-mcp] Failed to start:', error); + process.exit(1); + } +} diff --git a/src/mcp/tools/archive.ts b/src/mcp/tools/archive.ts new file mode 100644 index 00000000..701a6dd7 --- /dev/null +++ b/src/mcp/tools/archive.ts @@ -0,0 +1,89 @@ +/** + * MCP tool: archive + * + * Archive a completed change. + * Maps to `openspec archive` CLI command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { ArchiveCommand } from '../../core/archive.js'; + +const InputSchema = z.object({ + changeName: z.string().describe('Name of the change to archive'), + skipSpecs: z.boolean().default(false).describe('Skip updating specs during archive'), + noValidate: z.boolean().default(false).describe('Skip validation before archiving'), +}); + +export function registerArchiveTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'archive', + { + description: 'Archive a completed change. Moves the change to archive directory and optionally updates specs. Use this MCP tool instead of running `openspec archive` CLI command.', + inputSchema: InputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + const archiveCommand = new ArchiveCommand(); + + try { + // Capture output + const originalLog = console.log; + const originalError = console.error; + let output = ''; + + console.log = (...args: any[]) => { + output += args.map(String).join(' ') + '\n'; + originalLog(...args); + }; + console.error = (...args: any[]) => { + output += args.map(String).join(' ') + '\n'; + originalError(...args); + }; + + try { + await archiveCommand.execute(parsed.changeName, { + yes: true, + skipSpecs: parsed.skipSpecs, + noValidate: parsed.noValidate, + targetPath: pathConfig.specsRoot, + }); + } finally { + console.log = originalLog; + console.error = originalError; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + changeName: parsed.changeName, + message: `Change '${parsed.changeName}' archived successfully`, + output: output.trim(), + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/edit.ts b/src/mcp/tools/edit.ts new file mode 100644 index 00000000..0012f599 --- /dev/null +++ b/src/mcp/tools/edit.ts @@ -0,0 +1,155 @@ +/** + * MCP tool: edit + * + * Create or update change proposal resources (proposal.md, tasks.md, design.md, spec deltas). + * This tool enables agents to write files during the proposal workflow. + */ + +import { z } from 'zod'; +import { promises as fs } from 'fs'; +import path from 'path'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { + getChangeProposalPath, + getChangeTasksPath, + getChangeDesignPath, + getChangeSpecDeltaPath, +} from '../utils/path-resolver.js'; + +const ResourceTypeEnum = z.enum(['proposal', 'tasks', 'design', 'spec']); + +const BaseInputSchema = z.object({ + changeId: z.string().describe('The change ID (e.g., "add-user-auth")'), + resourceType: ResourceTypeEnum.describe('Type of resource to create/update'), + content: z.string().describe('Markdown content to write'), + capability: z.string().optional().describe('Capability name (required when resourceType is "spec")'), +}); + +const InputSchema = BaseInputSchema.refine( + (data) => data.resourceType !== 'spec' || data.capability !== undefined, + { + message: 'capability is required when resourceType is "spec"', + path: ['capability'], + } +); + +type ResourceType = z.infer; + +function getSuggestedNextActions(resourceType: ResourceType, resourceUri: string): string[] { + switch (resourceType) { + case 'proposal': + return [ + `Create tasks using edit tool with resourceType='tasks'`, + `Create spec deltas using edit tool with resourceType='spec'`, + `Validate with the validate tool`, + ]; + case 'tasks': + return [ + `Create spec deltas using edit tool with resourceType='spec'`, + `Validate with the validate tool`, + ]; + case 'spec': + return [ + `Read the resource at ${resourceUri} to verify`, + `Validate with the validate tool`, + ]; + case 'design': + return [`Validate with the validate tool`]; + } +} + +function getResourcePath( + pathConfig: PathConfig, + changeId: string, + resourceType: ResourceType, + capability?: string +): string { + switch (resourceType) { + case 'proposal': + return getChangeProposalPath(pathConfig, changeId); + case 'tasks': + return getChangeTasksPath(pathConfig, changeId); + case 'design': + return getChangeDesignPath(pathConfig, changeId); + case 'spec': + return getChangeSpecDeltaPath(pathConfig, changeId, capability!); + } +} + +function getResourceUri(changeId: string, resourceType: ResourceType, capability?: string): string { + switch (resourceType) { + case 'proposal': + return `openspec://changes/${changeId}/proposal`; + case 'tasks': + return `openspec://changes/${changeId}/tasks`; + case 'design': + return `openspec://changes/${changeId}/design`; + case 'spec': + return `openspec://changes/${changeId}/specs/${capability}`; + } +} + +export function registerEditTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'edit', + { + description: + 'Create or update change proposal resources. Use this tool to write proposal.md, tasks.md, design.md, or spec deltas when creating or modifying a change proposal.', + inputSchema: BaseInputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + const { changeId, resourceType, content, capability } = parsed; + + const filePath = getResourcePath(pathConfig, changeId, resourceType, capability); + const resourceUri = getResourceUri(changeId, resourceType, capability); + + try { + // Create directory structure + const dirPath = path.dirname(filePath); + await fs.mkdir(dirPath, { recursive: true }); + + // Write the file + await fs.writeFile(filePath, content, 'utf-8'); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `${resourceType} resource created/updated successfully`, + changeId, + resourceType, + capability, + path: filePath, + resourceUri, + suggestedNextActions: getSuggestedNextActions(resourceType, resourceUri), + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + changeId, + resourceType, + capability, + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts new file mode 100644 index 00000000..7d8117fb --- /dev/null +++ b/src/mcp/tools/index.ts @@ -0,0 +1,38 @@ +/** + * MCP Tools registration. + * + * Tools provide actions that map to OpenSpec CLI commands: + * - init: Initialize OpenSpec in project + * - list: List changes or specs + * - show: Show a change or spec + * - validate: Validate changes and specs + * - archive: Archive completed change + * - update_project_context: Update project.md + * - edit: Create/update change proposal resources + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { registerInitTool } from './init.js'; +import { registerListTool } from './list.js'; +import { registerShowTool } from './show.js'; +import { registerValidateTool } from './validate.js'; +import { registerArchiveTool } from './archive.js'; +import { registerProjectContextTool } from './project-context.js'; +import { registerEditTool } from './edit.js'; + +/** + * Register all OpenSpec tools with the MCP server. + */ +export function registerAllTools( + server: McpServer, + pathConfig: PathConfig +): void { + registerInitTool(server, pathConfig); + registerListTool(server, pathConfig); + registerShowTool(server, pathConfig); + registerValidateTool(server, pathConfig); + registerArchiveTool(server, pathConfig); + registerProjectContextTool(server, pathConfig); + registerEditTool(server, pathConfig); +} diff --git a/src/mcp/tools/init.ts b/src/mcp/tools/init.ts new file mode 100644 index 00000000..6c6f8c28 --- /dev/null +++ b/src/mcp/tools/init.ts @@ -0,0 +1,65 @@ +/** + * MCP tool: init + * + * Initialize OpenSpec in a project directory. + * Maps to `openspec init` CLI command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { InitCommand } from '../../core/init.js'; + +const InputSchema = z.object({}); + +export function registerInitTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'init', + { + description: 'Initialize OpenSpec in a project directory. Creates openspec/ directory structure. Note: AI tool integrations are not configured via this MCP tool. Use this MCP tool instead of running `openspec init` CLI command.', + inputSchema: InputSchema, + }, + async (input) => { + InputSchema.parse(input); + const initCommand = new InitCommand({ tools: 'none' }); + + try { + await initCommand.execute(pathConfig.specsRoot); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'OpenSpec initialized successfully', + path: pathConfig.specsRoot, + suggestedNextActions: [ + 'Read the `openspec://project` resource', + 'Help fill out the project context resource with details about project, tech stack, and conventions', + 'Run the `openspec` `update_project_context` tool to apply project context updates', + ], + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/list.ts b/src/mcp/tools/list.ts new file mode 100644 index 00000000..0f1a2f5c --- /dev/null +++ b/src/mcp/tools/list.ts @@ -0,0 +1,74 @@ +/** + * MCP tool: list + * + * List changes or specs. + * Maps to `openspec list` CLI command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { ListCommand } from '../../core/list.js'; + +const InputSchema = z.object({ + mode: z.enum(['changes', 'specs']).default('changes').describe('List changes (default) or specs'), +}); + +export function registerListTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'list', + { + description: 'bar List active changes or specifications. Returns JSON array of items with their status. Use this MCP tool instead of running `openspec list` CLI command.', + inputSchema: InputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + const listCommand = new ListCommand(); + + try { + // Capture output by redirecting console.log + const originalLog = console.log; + let output = ''; + console.log = (...args: any[]) => { + output += args.map(String).join(' ') + '\n'; + originalLog(...args); + }; + + await listCommand.execute(pathConfig.specsRoot, parsed.mode); + + console.log = originalLog; + + // Parse the output to extract structured data + // For now, return the raw output as JSON + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + mode: parsed.mode, + output: output.trim(), + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/project-context.ts b/src/mcp/tools/project-context.ts new file mode 100644 index 00000000..2b6716a9 --- /dev/null +++ b/src/mcp/tools/project-context.ts @@ -0,0 +1,124 @@ +/** + * MCP tool: update_project_context + * + * Update the project.md file with project context information. + * This is a new tool not directly mapped to a CLI command. + */ + +import { z } from 'zod'; +import { promises as fs } from 'fs'; +import path from 'path'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { getOpenSpecDir, getProjectPath } from '../utils/path-resolver.js'; + +const BaseInputSchema = z.object({ + content: z.string().optional().describe('Full content to write to project.md'), + sections: z.record(z.string(), z.string()).optional().describe('Sections to update as key-value pairs'), +}); + +const InputSchema = BaseInputSchema.refine( + (data) => data.content !== undefined || data.sections !== undefined, + { + message: 'Either content or sections must be provided', + } +); + +export function registerProjectContextTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'update_project_context', + { + description: 'Update openspec/project.md with project context information. Can update the entire file or specific sections. Use this MCP tool instead of manually editing the file or running CLI commands.', + inputSchema: BaseInputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + const openspecDir = getOpenSpecDir(pathConfig); + const projectFilePath = getProjectPath(pathConfig); + + try { + if (parsed.content) { + // Write full content + await fs.mkdir(openspecDir, { recursive: true }); + await fs.writeFile(projectFilePath, parsed.content, 'utf-8'); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Project context updated successfully', + path: projectFilePath, + }), + }, + ], + }; + } else if (parsed.sections) { + // Update specific sections + let currentContent = ''; + try { + currentContent = await fs.readFile(projectFilePath, 'utf-8'); + } catch { + // File doesn't exist, start with empty content + currentContent = ''; + } + + // Simple section replacement: look for ## SectionName and replace until next ## + let updatedContent = currentContent; + for (const [sectionName, sectionContent] of Object.entries(parsed.sections)) { + const sectionHeader = `## ${sectionName}`; + const sectionRegex = new RegExp( + `(## ${sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})([\\s\\S]*?)(?=##|$)`, + 'i' + ); + + if (sectionRegex.test(updatedContent)) { + // Replace existing section + updatedContent = updatedContent.replace( + sectionRegex, + `${sectionHeader}\n\n${sectionContent}\n\n` + ); + } else { + // Append new section + updatedContent += `\n\n${sectionHeader}\n\n${sectionContent}\n`; + } + } + + await fs.mkdir(openspecDir, { recursive: true }); + await fs.writeFile(projectFilePath, updatedContent, 'utf-8'); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Project context sections updated successfully', + path: projectFilePath, + updatedSections: Object.keys(parsed.sections), + }), + }, + ], + }; + } + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/show.ts b/src/mcp/tools/show.ts new file mode 100644 index 00000000..40b18ce8 --- /dev/null +++ b/src/mcp/tools/show.ts @@ -0,0 +1,121 @@ +/** + * MCP tool: show + * + * Show a change or spec. + * Maps to `openspec show` CLI command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { ChangeCommand } from '../../commands/change.js'; +import { SpecCommand } from '../../commands/spec.js'; + +const InputSchema = z.object({ + itemName: z.string().describe('Name of the change or spec to show'), + type: z.enum(['change', 'spec']).optional().describe('Type of item (change or spec). Auto-detected if not provided'), + json: z.boolean().default(true).describe('Return JSON output (default: true)'), +}); + +export function registerShowTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'show', + { + description: 'Show details of a change or specification. Returns JSON with item content and metadata. Use this MCP tool instead of running `openspec show` CLI command.', + inputSchema: InputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + + try { + // Capture JSON output + const originalLog = console.log; + let jsonOutput = ''; + + // Determine type and show item + let itemType = parsed.type; + if (!itemType) { + // Auto-detect type by trying both + const changeCmd = new ChangeCommand(); + const specCmd = new SpecCommand(); + + // Try change first + try { + console.log = (...args: any[]) => { + jsonOutput += args.map(String).join(' ') + '\n'; + // Don't call originalLog - would corrupt MCP stdio stream + }; + await changeCmd.show(parsed.itemName, { json: true, targetPath: pathConfig.specsRoot, noInteractive: true }); + itemType = 'change'; + } catch { + // Not a change, try spec + jsonOutput = ''; // Reset for spec attempt + try { + await specCmd.show(parsed.itemName, { json: true, targetPath: pathConfig.specsRoot }); + itemType = 'spec'; + } catch { + console.log = originalLog; + throw new Error(`Item '${parsed.itemName}' not found as change or spec`); + } + } + console.log = originalLog; + } else { + // Type specified, use appropriate command + console.log = (...args: any[]) => { + jsonOutput += args.map(String).join(' ') + '\n'; + // Don't call originalLog - would corrupt MCP stdio stream + }; + try { + if (itemType === 'change') { + const changeCmd = new ChangeCommand(); + await changeCmd.show(parsed.itemName, { json: parsed.json, targetPath: pathConfig.specsRoot, noInteractive: true }); + } else { + const specCmd = new SpecCommand(); + await specCmd.show(parsed.itemName, { json: parsed.json, targetPath: pathConfig.specsRoot }); + } + } finally { + console.log = originalLog; + } + } + + // Parse the JSON output + let result; + try { + result = JSON.parse(jsonOutput.trim()); + } catch { + result = { raw: jsonOutput.trim() }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + itemName: parsed.itemName, + type: itemType, + data: result, + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/tools/validate.ts b/src/mcp/tools/validate.ts new file mode 100644 index 00000000..db1c3c46 --- /dev/null +++ b/src/mcp/tools/validate.ts @@ -0,0 +1,105 @@ +/** + * MCP tool: validate + * + * Validate changes and specs. + * Maps to `openspec validate` CLI command. + */ + +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../utils/path-resolver.js'; +import { ValidateCommand } from '../../commands/validate.js'; + +const InputSchema = z.object({ + itemName: z.string().optional().describe('Name of the change or spec to validate. If not provided, validates all items based on flags'), + type: z.enum(['change', 'spec']).optional().describe('Type of item (change or spec). Auto-detected if not provided'), + all: z.boolean().optional().describe('Validate all changes and specs'), + changes: z.boolean().optional().describe('Validate all changes'), + specs: z.boolean().optional().describe('Validate all specs'), + strict: z.boolean().default(false).describe('Enable strict validation mode'), +}); + +export function registerValidateTool( + server: McpServer, + pathConfig: PathConfig +): void { + server.registerTool( + 'validate', + { + description: 'Validate changes and specifications. Returns JSON validation report with issues and status. Use this MCP tool instead of running `openspec validate` CLI command.', + inputSchema: InputSchema, + }, + async (input) => { + const parsed = InputSchema.parse(input); + + try { + // Capture JSON output + const originalLog = console.log; + const originalError = console.error; + let jsonOutput = ''; + let errorOutput = ''; + + console.log = (...args: any[]) => { + jsonOutput += args.map(String).join(' ') + '\n'; + originalLog(...args); + }; + console.error = (...args: any[]) => { + errorOutput += args.map(String).join(' ') + '\n'; + originalError(...args); + }; + + try { + const validateCommand = new ValidateCommand(); + await validateCommand.execute(parsed.itemName, { + type: parsed.type, + all: parsed.all, + changes: parsed.changes, + specs: parsed.specs, + strict: parsed.strict, + json: true, + noInteractive: true, + targetPath: pathConfig.specsRoot, + }); + } finally { + console.log = originalLog; + console.error = originalError; + } + + // Parse the JSON output + let result; + try { + result = JSON.parse(jsonOutput.trim() || errorOutput.trim()); + } catch { + result = { + output: jsonOutput.trim() || errorOutput.trim(), + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + result, + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/src/mcp/utils/context-loader.ts b/src/mcp/utils/context-loader.ts new file mode 100644 index 00000000..971a31c7 --- /dev/null +++ b/src/mcp/utils/context-loader.ts @@ -0,0 +1,60 @@ +/** + * Context loading utilities for OpenSpec MCP server. + * + * Provides async helpers to load AGENTS.md and project.md content, + * with safe fallbacks when files are missing. + */ + +import * as fs from 'fs/promises'; +import { getAgentsPath, getProjectPath, type PathConfig } from './path-resolver.js'; +import { TemplateManager } from '../../core/templates/index.js'; + +/** + * Load the AGENTS.md workflow instructions. + * + * @param pathConfig - PathConfig from resolveOpenSpecPaths + * @returns The AGENTS.md content, or a default template if not found + */ +export async function loadAgentsMarkdown(pathConfig: PathConfig): Promise { + const agentsFilePath = getAgentsPath(pathConfig); + + try { + return await fs.readFile(agentsFilePath, 'utf-8'); + } catch { + return getDefaultAgentsTemplate(); + } +} + +/** + * Load the project.md context file. + * + * @param pathConfig - PathConfig from resolveOpenSpecPaths + * @returns The project.md content, or a default template if not found + */ +export async function loadProjectMarkdown(pathConfig: PathConfig): Promise { + const projectFilePath = getProjectPath(pathConfig); + + try { + return await fs.readFile(projectFilePath, 'utf-8'); + } catch { + return getDefaultProjectTemplate(); + } +} + +function getDefaultAgentsTemplate(): string { + const templates = TemplateManager.getTemplates(); + const agentsTemplate = templates.find((t) => t.path === 'AGENTS.md'); + if (agentsTemplate && typeof agentsTemplate.content === 'string') { + return agentsTemplate.content; + } + return '# OpenSpec Instructions\n\nOpenSpec has not been initialized.'; +} + +function getDefaultProjectTemplate(): string { + const templates = TemplateManager.getTemplates(); + const projectTemplate = templates.find((t) => t.path === 'project.md'); + if (projectTemplate && typeof projectTemplate.content === 'string') { + return projectTemplate.content; + } + return '# Project Context\n\nProject context not available.'; +} diff --git a/src/mcp/utils/path-resolver.ts b/src/mcp/utils/path-resolver.ts new file mode 100644 index 00000000..8b9216ba --- /dev/null +++ b/src/mcp/utils/path-resolver.ts @@ -0,0 +1,227 @@ +/** + * Path resolution utilities for OpenSpec MCP server. + * + * Handles OPENSPEC_ROOT and OPENSPEC_AUTO_PROJECT_ROOT environment variables + * to support different deployment configurations: + * - Default: Specs in project directory + * - Fixed root: Centralized spec storage + * - Auto-project-root: Centralized but organized by project path + */ + +import * as path from 'path'; +import * as fs from 'fs'; + +export interface PathConfig { + /** Root directory where openspec/ folder is located */ + specsRoot: string; + /** Root directory of the project being worked on */ + projectRoot: string; + /** Whether auto-project-root mode is enabled */ + isAutoProjectRoot: boolean; +} + +/** + * Resolve OpenSpec paths based on environment configuration. + * + * @param projectPath - Optional project directory path (defaults to cwd) + * @returns PathConfig with resolved specsRoot and projectRoot + * + * @example + * // Default mode (no env vars): specs stored in project + * resolveOpenSpecPaths('/home/user/myproject') + * // => { specsRoot: '/home/user/myproject', projectRoot: '/home/user/myproject', isAutoProjectRoot: false } + * + * @example + * // Fixed root mode (OPENSPEC_ROOT=/home/user/.openspec) + * resolveOpenSpecPaths('/home/user/myproject') + * // => { specsRoot: '/home/user/.openspec', projectRoot: '/home/user/myproject', isAutoProjectRoot: false } + * + * @example + * // Auto-project-root mode (OPENSPEC_ROOT=/home/user/.openspec, OPENSPEC_AUTO_PROJECT_ROOT=true) + * resolveOpenSpecPaths('/home/user/code/myproject') + * // => { specsRoot: '/home/user/.openspec/code/myproject', projectRoot: '/home/user/code/myproject', isAutoProjectRoot: true } + */ +export function resolveOpenSpecPaths(projectPath?: string): PathConfig { + const openspecRoot = process.env.OPENSPEC_ROOT; + const autoProjectRoot = process.env.OPENSPEC_AUTO_PROJECT_ROOT === 'true'; + + const projectRoot = projectPath ? path.resolve(projectPath) : process.cwd(); + + if (!openspecRoot) { + return { specsRoot: projectRoot, projectRoot, isAutoProjectRoot: false }; + } + + const resolvedOpenspecRoot = path.resolve(openspecRoot); + + if (!autoProjectRoot) { + return { + specsRoot: resolvedOpenspecRoot, + projectRoot, + isAutoProjectRoot: false, + }; + } + + const relativeProjectPath = deriveRelativePath(projectRoot); + const specsRoot = path.join(resolvedOpenspecRoot, relativeProjectPath); + ensureDirectoryExists(specsRoot); + + return { specsRoot, projectRoot, isAutoProjectRoot: true }; +} + +function deriveRelativePath(projectRoot: string): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + + if (homeDir && projectRoot.startsWith(homeDir)) { + const relativePath = projectRoot.slice(homeDir.length); + return relativePath.replace(/^[/\\]+/, ''); + } + + return path.basename(projectRoot); +} + +function ensureDirectoryExists(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +/** + * Get the openspec/ directory path. + * + * @param config - PathConfig from resolveOpenSpecPaths + * @returns Absolute path to the openspec/ directory + * + * @example + * // Default mode: specs in project directory + * getOpenSpecDir({ specsRoot: '/home/user/project', projectRoot: '/home/user/project', isAutoProjectRoot: false }) + * // => '/home/user/project/openspec' + * + * @example + * // Fixed root mode: specs in centralized location + * getOpenSpecDir({ specsRoot: '/home/user/.openspec', projectRoot: '/home/user/project', isAutoProjectRoot: false }) + * // => '/home/user/.openspec/openspec' + * + * @example + * // Auto-project-root mode: specs organized by project path + * getOpenSpecDir({ specsRoot: '/home/user/.openspec/code/myapp', projectRoot: '/home/user/code/myapp', isAutoProjectRoot: true }) + * // => '/home/user/.openspec/code/myapp/openspec' + */ +export function getOpenSpecDir(config: PathConfig): string { + return path.join(config.specsRoot, 'openspec'); +} + +/** + * Get the path to a specific change directory. + * + * @param config - PathConfig from resolveOpenSpecPaths + * @param changeId - The change identifier (e.g., 'add-mcp-server') + * @returns Absolute path to the change directory + * + * @example + * // Default mode + * getChangePath({ specsRoot: '/home/user/project', projectRoot: '/home/user/project', isAutoProjectRoot: false }, 'add-mcp-server') + * // => '/home/user/project/openspec/changes/add-mcp-server' + * + * @example + * // Auto-project-root mode + * getChangePath({ specsRoot: '/home/user/.openspec/code/myapp', projectRoot: '/home/user/code/myapp', isAutoProjectRoot: true }, 'add-mcp-server') + * // => '/home/user/.openspec/code/myapp/openspec/changes/add-mcp-server' + */ +export function getChangePath(config: PathConfig, changeId: string): string { + return path.join(getOpenSpecDir(config), 'changes', changeId); +} + +/** + * Get the path to a specific spec file. + * + * @param config - PathConfig from resolveOpenSpecPaths + * @param capability - The capability name (e.g., 'user-auth') + * @returns Absolute path to the spec.md file + * + * @example + * // Default mode + * getSpecPath({ specsRoot: '/home/user/project', projectRoot: '/home/user/project', isAutoProjectRoot: false }, 'user-auth') + * // => '/home/user/project/openspec/specs/user-auth/spec.md' + * + * @example + * // Auto-project-root mode + * getSpecPath({ specsRoot: '/home/user/.openspec/code/myapp', projectRoot: '/home/user/code/myapp', isAutoProjectRoot: true }, 'user-auth') + * // => '/home/user/.openspec/code/myapp/openspec/specs/user-auth/spec.md' + */ +export function getSpecPath(config: PathConfig, capability: string): string { + return path.join(getOpenSpecDir(config), 'specs', capability, 'spec.md'); +} + +/** + * Get the specs directory path. + */ +export function getSpecsDir(config: PathConfig): string { + return path.join(getOpenSpecDir(config), 'specs'); +} + +/** + * Get the archive directory path. + */ +export function getArchiveDir(config: PathConfig): string { + return path.join(getOpenSpecDir(config), 'changes', 'archive'); +} + +/** + * Get the changes directory path. + */ +export function getChangesDir(config: PathConfig): string { + return path.join(getOpenSpecDir(config), 'changes'); +} + +/** + * Get the AGENTS.md file path. + */ +export function getAgentsPath(config: PathConfig): string { + return path.join(getOpenSpecDir(config), 'AGENTS.md'); +} + +/** + * Get the project.md file path. + */ +export function getProjectPath(config: PathConfig): string { + return path.join(getOpenSpecDir(config), 'project.md'); +} + +/** + * Get the proposal.md file path for a change. + */ +export function getChangeProposalPath(config: PathConfig, changeId: string): string { + return path.join(getChangePath(config, changeId), 'proposal.md'); +} + +/** + * Get the tasks.md file path for a change. + */ +export function getChangeTasksPath(config: PathConfig, changeId: string): string { + return path.join(getChangePath(config, changeId), 'tasks.md'); +} + +/** + * Get the design.md file path for a change. + */ +export function getChangeDesignPath(config: PathConfig, changeId: string): string { + return path.join(getChangePath(config, changeId), 'design.md'); +} + +/** + * Get the specs directory path within a change. + */ +export function getChangeSpecsDir(config: PathConfig, changeId: string): string { + return path.join(getChangePath(config, changeId), 'specs'); +} + +/** + * Get the spec delta file path for a capability within a change. + */ +export function getChangeSpecDeltaPath( + config: PathConfig, + changeId: string, + capability: string +): string { + return path.join(getChangeSpecsDir(config, changeId), capability, 'spec.md'); +} diff --git a/test/commands/mcp.test.ts b/test/commands/mcp.test.ts new file mode 100644 index 00000000..bb8dc5af --- /dev/null +++ b/test/commands/mcp.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { createMcpCommand } from '../../src/commands/mcp.js'; +import * as mcpModule from '../../src/mcp/index.js'; + +vi.mock('../../src/mcp/index.js', () => ({ + startMcpServer: vi.fn(), +})); + +describe('createMcpCommand', () => { + let mockStartMcpServer: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockStartMcpServer = vi.mocked(mcpModule.startMcpServer); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('command creation', () => { + it('should create a command with name "mcp"', () => { + const command = createMcpCommand(); + expect(command.name()).toBe('mcp'); + }); + + it('should have correct description', () => { + const command = createMcpCommand(); + expect(command.description()).toBe('Start stdio-based MCP server for AI agent integration'); + }); + + it('should have --debug option', () => { + const command = createMcpCommand(); + const opts = command.opts(); + expect(command.options).toHaveLength(1); + const debugOption = command.options[0]; + expect(debugOption.flags).toContain('--debug'); + expect(debugOption.description).toBe('Enable debug logging to stderr'); + }); + }); + + describe('command execution', () => { + it('should call startMcpServer with debug: false when --debug is not provided', async () => { + const { Command } = await import('commander'); + const program = new Command(); + const mcpCommand = createMcpCommand(); + program.addCommand(mcpCommand); + mockStartMcpServer.mockResolvedValue(undefined); + + await program.parseAsync(['node', 'test', 'mcp']); + + expect(mockStartMcpServer).toHaveBeenCalledTimes(1); + expect(mockStartMcpServer).toHaveBeenCalledWith({ + debug: false, + }); + }); + + it('should call startMcpServer with debug: true when --debug is provided', async () => { + const { Command } = await import('commander'); + const program = new Command(); + const mcpCommand = createMcpCommand(); + program.addCommand(mcpCommand); + mockStartMcpServer.mockResolvedValue(undefined); + + await program.parseAsync(['node', 'test', 'mcp', '--debug']); + + expect(mockStartMcpServer).toHaveBeenCalledTimes(1); + expect(mockStartMcpServer).toHaveBeenCalledWith({ + debug: true, + }); + }); + + it('should handle startMcpServer errors', async () => { + const { Command } = await import('commander'); + const program = new Command(); + const mcpCommand = createMcpCommand(); + program.addCommand(mcpCommand); + const error = new Error('Server startup failed'); + mockStartMcpServer.mockRejectedValue(error); + + await expect(program.parseAsync(['node', 'test', 'mcp'])).rejects.toThrow('Server startup failed'); + expect(mockStartMcpServer).toHaveBeenCalledTimes(1); + }); + }); + + describe('integration with Commander.js', () => { + it('should be registerable with program.addCommand', async () => { + const { Command } = await import('commander'); + const program = new Command(); + const mcpCommand = createMcpCommand(); + + program.addCommand(mcpCommand); + + expect(program.commands).toHaveLength(1); + expect(program.commands[0].name()).toBe('mcp'); + }); + + it('should have help text with description and options', () => { + const command = createMcpCommand(); + const helpText = command.helpInformation(); + + expect(helpText).toContain('Start stdio-based MCP server'); + expect(helpText).toContain('--debug'); + expect(helpText).toContain('Enable debug logging to stderr'); + }); + }); +}); diff --git a/test/commands/targetPath.test.ts b/test/commands/targetPath.test.ts new file mode 100644 index 00000000..27312641 --- /dev/null +++ b/test/commands/targetPath.test.ts @@ -0,0 +1,364 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { ValidateCommand } from '../../src/commands/validate.js'; +import { ChangeCommand } from '../../src/commands/change.js'; +import { SpecCommand } from '../../src/commands/spec.js'; +import { ArchiveCommand } from '../../src/core/archive.js'; + +describe('targetPath option', () => { + let tempDir: string; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-targetpath-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create OpenSpec structure + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'archive'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + + console.log = vi.fn(); + console.error = vi.fn(); + }); + + afterEach(async () => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + vi.clearAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('ValidateCommand with targetPath', () => { + it('should validate a spec using targetPath', async () => { + // Create a valid spec + const specDir = path.join(tempDir, 'openspec', 'specs', 'test-spec'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + `# Test Spec + +## Purpose +Test purpose. + +## Requirements + +### Requirement: Test requirement SHALL pass + +#### Scenario: Validation +- **GIVEN** a valid spec +- **WHEN** validated +- **THEN** passes` + ); + + const cmd = new ValidateCommand(); + await cmd.execute('test-spec', { + type: 'spec', + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + const output = (console.log as ReturnType).mock.calls + .map((call: any[]) => call.join(' ')) + .join('\n'); + expect(output).toContain('valid'); + }); + + it('should validate a change using targetPath', async () => { + // Create a change with delta specs + const changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Test Change\n\n## Why\nTest reason.\n\n## What Changes\n- **alpha:** Test' + ); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: New feature SHALL work + +#### Scenario: Feature works +- **GIVEN** the feature +- **WHEN** used +- **THEN** works` + ); + + const cmd = new ValidateCommand(); + await cmd.execute('test-change', { + type: 'change', + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + }); + + it('should validate all specs using targetPath', async () => { + const cmd = new ValidateCommand(); + await cmd.execute(undefined, { + specs: true, + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + }); + + it('should validate all changes using targetPath', async () => { + const cmd = new ValidateCommand(); + await cmd.execute(undefined, { + changes: true, + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + }); + }); + + describe('ChangeCommand with targetPath', () => { + it('should show a change using targetPath', async () => { + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Test Change\n\n## Why\nTest reason.\n\n## What Changes\n- **alpha:** Test' + ); + + const cmd = new ChangeCommand(); + await cmd.show('test-change', { + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + const output = (console.log as ReturnType).mock.calls + .map((call: any[]) => call.join(' ')) + .join('\n'); + const parsed = JSON.parse(output); + expect(parsed.id).toBe('test-change'); + }); + + it('should list changes using targetPath', async () => { + // Create changes + const change1 = path.join(tempDir, 'openspec', 'changes', 'change-a'); + const change2 = path.join(tempDir, 'openspec', 'changes', 'change-b'); + await fs.mkdir(change1, { recursive: true }); + await fs.mkdir(change2, { recursive: true }); + await fs.writeFile(path.join(change1, 'proposal.md'), '# Change A'); + await fs.writeFile(path.join(change2, 'proposal.md'), '# Change B'); + + const cmd = new ChangeCommand(); + await cmd.list({ + json: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + const output = (console.log as ReturnType).mock.calls + .map((call: any[]) => call.join(' ')) + .join('\n'); + const parsed = JSON.parse(output); + expect(parsed).toHaveLength(2); + expect(parsed.map((c: any) => c.id).sort()).toEqual(['change-a', 'change-b']); + }); + + it('should validate a change using targetPath', async () => { + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'valid-change'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Valid Change\n\n## Why\nTest.\n\n## What Changes\n- **alpha:** Test' + ); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: Test SHALL pass + +#### Scenario: Pass +- **GIVEN** test +- **WHEN** run +- **THEN** pass` + ); + + const cmd = new ChangeCommand(); + await cmd.validate('valid-change', { + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + }); + }); + + describe('SpecCommand with targetPath', () => { + it('should show a spec using targetPath', async () => { + // Create a spec + const specDir = path.join(tempDir, 'openspec', 'specs', 'test-spec'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + `# Test Spec + +## Purpose +Test purpose. + +## Requirements + +### Requirement: Test SHALL work + +#### Scenario: Works +- **GIVEN** test +- **WHEN** run +- **THEN** works` + ); + + const cmd = new SpecCommand(); + await cmd.show('test-spec', { + json: true, + noInteractive: true, + targetPath: tempDir + }); + + expect(console.log).toHaveBeenCalled(); + const output = (console.log as ReturnType).mock.calls + .map((call: any[]) => call.join(' ')) + .join('\n'); + const parsed = JSON.parse(output); + expect(parsed.id).toBe('test-spec'); + }); + }); + + describe('ArchiveCommand with targetPath', () => { + it('should archive a change using targetPath', async () => { + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'archive-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Task 1\n- [x] Task 2' + ); + + const cmd = new ArchiveCommand(); + await cmd.execute('archive-change', { + yes: true, + skipSpecs: true, + noValidate: true, + targetPath: tempDir + }); + + // Verify change was archived + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + expect(archives[0]).toContain('archive-change'); + + // Verify original change no longer exists + await expect(fs.access(changeDir)).rejects.toThrow(); + }); + + it('should update specs in targetPath during archive', async () => { + // Create a change with specs + const changeDir = path.join(tempDir, 'openspec', 'changes', 'spec-update-change'); + const changeSpecDir = path.join(changeDir, 'specs', 'new-capability'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Done' + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: New capability SHALL exist + +#### Scenario: Exists +- **GIVEN** the capability +- **WHEN** checked +- **THEN** exists` + ); + + const cmd = new ArchiveCommand(); + await cmd.execute('spec-update-change', { + yes: true, + noValidate: true, + targetPath: tempDir + }); + + // Verify spec was created in targetPath + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'new-capability', 'spec.md'); + const specContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(specContent).toContain('New capability SHALL exist'); + }); + + it('should find specs in targetPath for updates', async () => { + // Create existing spec + const mainSpecDir = path.join(tempDir, 'openspec', 'specs', 'existing'); + await fs.mkdir(mainSpecDir, { recursive: true }); + await fs.writeFile( + path.join(mainSpecDir, 'spec.md'), + `# Existing Specification + +## Purpose +Test purpose. + +## Requirements + +### Requirement: Original feature SHALL work + +#### Scenario: Works +- **GIVEN** feature +- **WHEN** used +- **THEN** works` + ); + + // Create a change that modifies the spec + const changeDir = path.join(tempDir, 'openspec', 'changes', 'modify-change'); + const changeSpecDir = path.join(changeDir, 'specs', 'existing'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Done' + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: New feature SHALL also work + +#### Scenario: Also works +- **GIVEN** new feature +- **WHEN** used +- **THEN** works` + ); + + const cmd = new ArchiveCommand(); + await cmd.execute('modify-change', { + yes: true, + noValidate: true, + targetPath: tempDir + }); + + // Verify both requirements exist + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'existing', 'spec.md'); + const specContent = await fs.readFile(mainSpecPath, 'utf-8'); + expect(specContent).toContain('Original feature SHALL work'); + expect(specContent).toContain('New feature SHALL also work'); + }); + }); +}); diff --git a/test/mcp/index.test.ts b/test/mcp/index.test.ts new file mode 100644 index 00000000..a6bace63 --- /dev/null +++ b/test/mcp/index.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; + +describe('MCP module exports', () => { + it('should export startMcpServer', async () => { + const mod = await import('../../src/mcp/index.js'); + + expect(mod).toHaveProperty('startMcpServer'); + expect(typeof mod.startMcpServer).toBe('function'); + }); + + it('should re-export from server module', async () => { + const indexMod = await import('../../src/mcp/index.js'); + const serverMod = await import('../../src/mcp/server.js'); + + expect(indexMod.startMcpServer).toBe(serverMod.startMcpServer); + }); +}); diff --git a/test/mcp/prompts/index.test.ts b/test/mcp/prompts/index.test.ts new file mode 100644 index 00000000..5411757b --- /dev/null +++ b/test/mcp/prompts/index.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerAllPrompts } from '../../../src/mcp/prompts/index.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Prompts', () => { + describe('registerAllPrompts', () => { + it('should export registerAllPrompts function', () => { + expect(typeof registerAllPrompts).toBe('function'); + }); + + it('should register all 3 prompts with the MCP server', () => { + const mockServer = { + registerPrompt: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllPrompts(mockServer, mockPathConfig); + + // Should register 3 prompts: openspec-propose, openspec-apply, openspec-archive + expect(mockServer.registerPrompt).toHaveBeenCalledTimes(3); + + // Verify prompt names + const promptNames = (mockServer.registerPrompt as ReturnType).mock.calls.map( + (call: any[]) => call[0] + ); + expect(promptNames).toContain('openspec-propose'); + expect(promptNames).toContain('openspec-apply'); + expect(promptNames).toContain('openspec-archive'); + }); + + it('should register prompts with correct titles', () => { + const mockServer = { + registerPrompt: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllPrompts(mockServer, mockPathConfig); + + const calls = (mockServer.registerPrompt as ReturnType).mock.calls; + + // Verify each prompt has the correct title + const proposeCall = calls.find((call: any[]) => call[0] === 'openspec-propose'); + expect(proposeCall?.[1]?.title).toBe('openspec-propose'); + + const applyCall = calls.find((call: any[]) => call[0] === 'openspec-apply'); + expect(applyCall?.[1]?.title).toBe('openspec-apply'); + + const archiveCall = calls.find((call: any[]) => call[0] === 'openspec-archive'); + expect(archiveCall?.[1]?.title).toBe('openspec-archive'); + }); + + it('should pass pathConfig to each prompt registration', () => { + const mockServer = { + registerPrompt: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/custom/path', + projectRoot: '/custom/path', + isAutoProjectRoot: true, + }; + + registerAllPrompts(mockServer, mockPathConfig); + + // Each prompt registration receives the server and pathConfig + expect(mockServer.registerPrompt).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/mcp/resources/archive.test.ts b/test/mcp/resources/archive.test.ts new file mode 100644 index 00000000..a4306187 --- /dev/null +++ b/test/mcp/resources/archive.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerArchiveResource } from '../../../src/mcp/resources/archive.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('archive resource', () => { + let testDir: string; + let openspecDir: string; + let archiveDir: string; + let mockServer: McpServer; + let registerResourceSpy: ReturnType; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-archive-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + archiveDir = path.join(openspecDir, 'changes', 'archive'); + await fs.mkdir(archiveDir, { recursive: true }); + + registerResourceSpy = vi.fn(); + mockServer = { + registerResource: registerResourceSpy, + } as unknown as McpServer; + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + it('should register archive resource', () => { + registerArchiveResource(mockServer, createPathConfig()); + + expect(registerResourceSpy).toHaveBeenCalledTimes(1); + const call = registerResourceSpy.mock.calls[0]; + expect(call[0]).toBe('archive'); + expect(call[1]).toBe('openspec://archive'); + expect(call[2]).toMatchObject({ + title: 'Archived Changes', + description: expect.stringContaining('archived'), + mimeType: 'text/markdown', + }); + }); + + it('should return markdown list when archived changes exist', async () => { + await fs.mkdir(path.join(archiveDir, '2024-01-15-add-feature'), { + recursive: true, + }); + await fs.mkdir(path.join(archiveDir, '2024-01-20-update-api'), { + recursive: true, + }); + await fs.mkdir(path.join(archiveDir, '2024-01-10-fix-bug'), { + recursive: true, + }); + + registerArchiveResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://archive' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Archived Changes'); + expect(result.contents[0].text).toContain('- 2024-01-20-update-api'); + expect(result.contents[0].text).toContain('- 2024-01-15-add-feature'); + expect(result.contents[0].text).toContain('- 2024-01-10-fix-bug'); + // Should be sorted newest first + const lines = result.contents[0].text.split('\n'); + const updateApiIndex = lines.findIndex((l) => l.includes('update-api')); + const addFeatureIndex = lines.findIndex((l) => l.includes('add-feature')); + const fixBugIndex = lines.findIndex((l) => l.includes('fix-bug')); + expect(updateApiIndex).toBeLessThan(addFeatureIndex); + expect(addFeatureIndex).toBeLessThan(fixBugIndex); + }); + + it('should return empty list message when no archived changes exist', async () => { + registerArchiveResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://archive' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Archived Changes'); + expect(result.contents[0].text).toContain('No archived changes yet.'); + }); + + it('should handle missing archive directory gracefully', async () => { + await fs.rm(archiveDir, { recursive: true, force: true }); + + registerArchiveResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://archive' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No archived changes yet.'); + }); + + it('should only include directories, not files', async () => { + await fs.mkdir(path.join(archiveDir, '2024-01-15-change'), { + recursive: true, + }); + await fs.writeFile(path.join(archiveDir, 'readme.txt'), 'Not a change'); + + registerArchiveResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://archive' }; + const result = await handler(mockUri); + + expect(result.contents[0].text).toContain('2024-01-15-change'); + expect(result.contents[0].text).not.toContain('readme.txt'); + }); +}); diff --git a/test/mcp/resources/changes.test.ts b/test/mcp/resources/changes.test.ts new file mode 100644 index 00000000..ced41070 --- /dev/null +++ b/test/mcp/resources/changes.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerChangesResources } from '../../../src/mcp/resources/changes.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('changes resources', () => { + let testDir: string; + let openspecDir: string; + let changesDir: string; + let mockServer: McpServer; + let registerResourceSpy: ReturnType; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-changes-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + changesDir = path.join(openspecDir, 'changes'); + await fs.mkdir(changesDir, { recursive: true }); + + registerResourceSpy = vi.fn(); + mockServer = { + registerResource: registerResourceSpy, + } as unknown as McpServer; + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + it('should register all change resources', () => { + registerChangesResources(mockServer, createPathConfig()); + + expect(registerResourceSpy).toHaveBeenCalledTimes(7); + + // Check changes list + expect(registerResourceSpy.mock.calls[0][0]).toBe('changes-list'); + expect(registerResourceSpy.mock.calls[0][1]).toBe('openspec://changes'); + + // Check individual change (full) + expect(registerResourceSpy.mock.calls[1][0]).toBe('change'); + expect(registerResourceSpy.mock.calls[1][1]).toBeInstanceOf(ResourceTemplate); + + // Check proposal + expect(registerResourceSpy.mock.calls[2][0]).toBe('change-proposal'); + expect(registerResourceSpy.mock.calls[2][1]).toBeInstanceOf(ResourceTemplate); + + // Check tasks + expect(registerResourceSpy.mock.calls[3][0]).toBe('change-tasks'); + expect(registerResourceSpy.mock.calls[3][1]).toBeInstanceOf(ResourceTemplate); + + // Check design + expect(registerResourceSpy.mock.calls[4][0]).toBe('change-design'); + expect(registerResourceSpy.mock.calls[4][1]).toBeInstanceOf(ResourceTemplate); + + // Check spec deltas list + expect(registerResourceSpy.mock.calls[5][0]).toBe('change-specs-list'); + expect(registerResourceSpy.mock.calls[5][1]).toBeInstanceOf(ResourceTemplate); + + // Check individual spec delta + expect(registerResourceSpy.mock.calls[6][0]).toBe('change-spec-delta'); + expect(registerResourceSpy.mock.calls[6][1]).toBeInstanceOf(ResourceTemplate); + }); + + describe('changes list resource', () => { + it('should return markdown list when changes exist', async () => { + await fs.mkdir(path.join(changesDir, 'add-feature'), { recursive: true }); + await fs.mkdir(path.join(changesDir, 'update-api'), { recursive: true }); + await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true }); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://changes' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Active Changes'); + expect(result.contents[0].text).toContain('[add-feature](openspec://changes/add-feature)'); + expect(result.contents[0].text).toContain('[update-api](openspec://changes/update-api)'); + expect(result.contents[0].text).not.toContain('archive'); + }); + + it('should return empty list message when no changes exist', async () => { + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://changes' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Active Changes'); + expect(result.contents[0].text).toContain('No active changes.'); + }); + + it('should handle missing changes directory gracefully', async () => { + await fs.rm(changesDir, { recursive: true, force: true }); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://changes' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No active changes.'); + }); + }); + + describe('individual change resource', () => { + it('should return all change files when they exist', async () => { + const changeId = 'add-feature'; + const changePath = path.join(changesDir, changeId); + await fs.mkdir(changePath, { recursive: true }); + + const proposal = '# Proposal\n\nWhy and what.'; + const tasks = '## Tasks\n\n- [ ] Task 1'; + const design = '# Design\n\nTechnical decisions.'; + + await fs.writeFile(path.join(changePath, 'proposal.md'), proposal); + await fs.writeFile(path.join(changePath, 'tasks.md'), tasks); + await fs.writeFile(path.join(changePath, 'design.md'), design); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[1][3]; + const mockUri = { href: `openspec://changes/${changeId}` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(3); + expect(result.contents[0].text).toBe(proposal); + expect(result.contents[0].uri).toBe(`openspec://changes/${changeId}/proposal`); + expect(result.contents[1].text).toBe(tasks); + expect(result.contents[1].uri).toBe(`openspec://changes/${changeId}/tasks`); + expect(result.contents[2].text).toBe(design); + expect(result.contents[2].uri).toBe(`openspec://changes/${changeId}/design`); + }); + + it('should return only existing files', async () => { + const changeId = 'partial-change'; + const changePath = path.join(changesDir, changeId); + await fs.mkdir(changePath, { recursive: true }); + + const proposal = '# Proposal\n\nContent.'; + await fs.writeFile(path.join(changePath, 'proposal.md'), proposal); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[1][3]; + const mockUri = { href: `openspec://changes/${changeId}` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(proposal); + }); + + it('should return not found message when change does not exist', async () => { + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[1][3]; + const mockUri = { href: 'openspec://changes/nonexistent' }; + const result = await handler(mockUri, { changeId: 'nonexistent' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# nonexistent'); + expect(result.contents[0].text).toContain('Change not found.'); + }); + }); + + describe('change file resources', () => { + it('should return proposal content', async () => { + const changeId = 'test-change'; + const changePath = path.join(changesDir, changeId); + await fs.mkdir(changePath, { recursive: true }); + const proposal = '# Proposal\n\nTest proposal.'; + await fs.writeFile(path.join(changePath, 'proposal.md'), proposal); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[2][3]; + const mockUri = { href: `openspec://changes/${changeId}/proposal` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(proposal); + }); + + it('should return tasks content', async () => { + const changeId = 'test-change'; + const changePath = path.join(changesDir, changeId); + await fs.mkdir(changePath, { recursive: true }); + const tasks = '## Tasks\n\n- [x] Done'; + await fs.writeFile(path.join(changePath, 'tasks.md'), tasks); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[3][3]; + const mockUri = { href: `openspec://changes/${changeId}/tasks` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(tasks); + }); + + it('should return design content', async () => { + const changeId = 'test-change'; + const changePath = path.join(changesDir, changeId); + await fs.mkdir(changePath, { recursive: true }); + const design = '# Design\n\nTechnical design.'; + await fs.writeFile(path.join(changePath, 'design.md'), design); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[4][3]; + const mockUri = { href: `openspec://changes/${changeId}/design` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(design); + }); + + it('should return "Not found" when file does not exist', async () => { + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[2][3]; + const mockUri = { href: 'openspec://changes/nonexistent/proposal' }; + const result = await handler(mockUri, { changeId: 'nonexistent' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe('Not found'); + }); + }); + + describe('spec deltas list resource', () => { + it('should return markdown list when spec deltas exist', async () => { + const changeId = 'add-feature'; + const changePath = path.join(changesDir, changeId, 'specs'); + await fs.mkdir(path.join(changePath, 'user-auth'), { recursive: true }); + await fs.mkdir(path.join(changePath, 'notifications'), { recursive: true }); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[5][3]; + const mockUri = { href: `openspec://changes/${changeId}/specs` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Spec Deltas for add-feature'); + expect(result.contents[0].text).toContain( + '[user-auth](openspec://changes/add-feature/specs/user-auth)' + ); + expect(result.contents[0].text).toContain( + '[notifications](openspec://changes/add-feature/specs/notifications)' + ); + }); + + it('should return empty message when no spec deltas exist', async () => { + const changeId = 'add-feature'; + await fs.mkdir(path.join(changesDir, changeId), { recursive: true }); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[5][3]; + const mockUri = { href: `openspec://changes/${changeId}/specs` }; + const result = await handler(mockUri, { changeId }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No spec deltas defined.'); + }); + + it('should handle missing specs directory gracefully', async () => { + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[5][3]; + const mockUri = { href: 'openspec://changes/nonexistent/specs' }; + const result = await handler(mockUri, { changeId: 'nonexistent' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No spec deltas defined.'); + }); + }); + + describe('individual spec delta resource', () => { + it('should return spec delta content', async () => { + const changeId = 'add-feature'; + const capability = 'user-auth'; + const specPath = path.join(changesDir, changeId, 'specs', capability); + await fs.mkdir(specPath, { recursive: true }); + + const specContent = '## ADDED Requirements\n\n### Requirement: OAuth Login'; + await fs.writeFile(path.join(specPath, 'spec.md'), specContent); + + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[6][3]; + const mockUri = { href: `openspec://changes/${changeId}/specs/${capability}` }; + const result = await handler(mockUri, { changeId, capability }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(specContent); + }); + + it('should return not found message when spec delta does not exist', async () => { + registerChangesResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[6][3]; + const mockUri = { href: 'openspec://changes/test/specs/missing' }; + const result = await handler(mockUri, { changeId: 'test', capability: 'missing' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('Spec delta for missing not found.'); + }); + }); +}); diff --git a/test/mcp/resources/index.test.ts b/test/mcp/resources/index.test.ts new file mode 100644 index 00000000..0f634495 --- /dev/null +++ b/test/mcp/resources/index.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerAllResources } from '../../../src/mcp/resources/index.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Resources', () => { + describe('registerAllResources', () => { + it('should export registerAllResources function', () => { + expect(typeof registerAllResources).toBe('function'); + }); + + it('should register all resource types', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllResources(mockServer, mockPathConfig); + + // Should register: instructions, project, specs-list, spec, changes-list, change, change-proposal, change-tasks, change-design, change-specs-list, change-spec-delta, archive + expect(mockServer.registerResource).toHaveBeenCalledTimes(12); + }); + + it('should accept McpServer and PathConfig parameters', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + expect(() => { + registerAllResources(mockServer, mockPathConfig); + }).not.toThrow(); + }); + }); +}); diff --git a/test/mcp/resources/instructions.test.ts b/test/mcp/resources/instructions.test.ts new file mode 100644 index 00000000..a671816d --- /dev/null +++ b/test/mcp/resources/instructions.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerInstructionsResource } from '../../../src/mcp/resources/instructions.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('instructions resource', () => { + let testDir: string; + let openspecDir: string; + let mockServer: McpServer; + let registerResourceSpy: ReturnType; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-instructions-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + + registerResourceSpy = vi.fn(); + mockServer = { + registerResource: registerResourceSpy, + } as unknown as McpServer; + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + it('should register instructions resource', () => { + registerInstructionsResource(mockServer, createPathConfig()); + + expect(registerResourceSpy).toHaveBeenCalledTimes(1); + const call = registerResourceSpy.mock.calls[0]; + expect(call[0]).toBe('instructions'); + expect(call[1]).toBe('openspec://instructions'); + expect(call[2]).toMatchObject({ + title: 'OpenSpec Instructions', + description: expect.stringContaining('AGENTS.md'), + mimeType: 'text/markdown', + }); + }); + + it('should return AGENTS.md content when file exists', async () => { + const content = '# Custom Agents Instructions\n\nTest content here.'; + await fs.writeFile(path.join(openspecDir, 'AGENTS.md'), content); + + registerInstructionsResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://instructions' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(content); + expect(result.contents[0].uri).toBe('openspec://instructions'); + expect(result.contents[0].mimeType).toBe('text/markdown'); + }); + + it('should return default template when AGENTS.md does not exist', async () => { + registerInstructionsResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://instructions' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# OpenSpec Instructions'); + }); + + it('should return default template when openspec directory does not exist', async () => { + await fs.rm(openspecDir, { recursive: true, force: true }); + + registerInstructionsResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://instructions' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# OpenSpec Instructions'); + }); +}); diff --git a/test/mcp/resources/project.test.ts b/test/mcp/resources/project.test.ts new file mode 100644 index 00000000..f81a66b9 --- /dev/null +++ b/test/mcp/resources/project.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerProjectResource } from '../../../src/mcp/resources/project.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('project resource', () => { + let testDir: string; + let openspecDir: string; + let mockServer: McpServer; + let registerResourceSpy: ReturnType; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-project-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + + registerResourceSpy = vi.fn(); + mockServer = { + registerResource: registerResourceSpy, + } as unknown as McpServer; + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + it('should register project resource', () => { + registerProjectResource(mockServer, createPathConfig()); + + expect(registerResourceSpy).toHaveBeenCalledTimes(1); + const call = registerResourceSpy.mock.calls[0]; + expect(call[0]).toBe('project'); + expect(call[1]).toBe('openspec://project'); + expect(call[2]).toMatchObject({ + title: 'Project Context', + mimeType: 'text/markdown', + }); + expect(call[2].description.toLowerCase()).toContain('project context'); + }); + + it('should return project.md content when file exists', async () => { + const content = '# My Project\n\nCustom project details.'; + await fs.writeFile(path.join(openspecDir, 'project.md'), content); + + registerProjectResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://project' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(content); + expect(result.contents[0].uri).toBe('openspec://project'); + expect(result.contents[0].mimeType).toBe('text/markdown'); + }); + + it('should return default template when project.md does not exist', async () => { + registerProjectResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://project' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Project Context'); + }); + + it('should return default template when openspec directory does not exist', async () => { + await fs.rm(openspecDir, { recursive: true, force: true }); + + registerProjectResource(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://project' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Project Context'); + }); +}); diff --git a/test/mcp/resources/specs.test.ts b/test/mcp/resources/specs.test.ts new file mode 100644 index 00000000..967f902b --- /dev/null +++ b/test/mcp/resources/specs.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerSpecsResources } from '../../../src/mcp/resources/specs.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('specs resources', () => { + let testDir: string; + let openspecDir: string; + let specsDir: string; + let mockServer: McpServer; + let registerResourceSpy: ReturnType; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-specs-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + specsDir = path.join(openspecDir, 'specs'); + await fs.mkdir(specsDir, { recursive: true }); + + registerResourceSpy = vi.fn(); + mockServer = { + registerResource: registerResourceSpy, + } as unknown as McpServer; + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + it('should register specs list and individual spec resources', () => { + registerSpecsResources(mockServer, createPathConfig()); + + expect(registerResourceSpy).toHaveBeenCalledTimes(2); + + // Check specs list registration + const listCall = registerResourceSpy.mock.calls[0]; + expect(listCall[0]).toBe('specs-list'); + expect(listCall[1]).toBe('openspec://specs'); + + // Check individual spec registration + const specCall = registerResourceSpy.mock.calls[1]; + expect(specCall[0]).toBe('spec'); + expect(specCall[1]).toBeInstanceOf(ResourceTemplate); + }); + + describe('specs list resource', () => { + it('should return markdown list when specs exist', async () => { + await fs.mkdir(path.join(specsDir, 'user-auth'), { recursive: true }); + await fs.mkdir(path.join(specsDir, 'payment'), { recursive: true }); + + registerSpecsResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://specs' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Specifications'); + expect(result.contents[0].text).toContain('[user-auth](openspec://specs/user-auth)'); + expect(result.contents[0].text).toContain('[payment](openspec://specs/payment)'); + }); + + it('should return empty list message when no specs exist', async () => { + registerSpecsResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://specs' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# Specifications'); + expect(result.contents[0].text).toContain('No specifications found.'); + }); + + it('should handle missing specs directory gracefully', async () => { + await fs.rm(specsDir, { recursive: true, force: true }); + + registerSpecsResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[0][3]; + const mockUri = { href: 'openspec://specs' }; + const result = await handler(mockUri); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('No specifications found.'); + }); + }); + + describe('individual spec resource', () => { + it('should return spec content when spec.md exists', async () => { + const capability = 'user-auth'; + const specContent = '# User Authentication\n\nSpec content here.'; + await fs.mkdir(path.join(specsDir, capability), { recursive: true }); + await fs.writeFile( + path.join(specsDir, capability, 'spec.md'), + specContent + ); + + registerSpecsResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[1][3]; + const mockUri = { href: `openspec://specs/${capability}` }; + const result = await handler(mockUri, { capability }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe(specContent); + expect(result.contents[0].uri).toBe(`openspec://specs/${capability}`); + expect(result.contents[0].mimeType).toBe('text/markdown'); + }); + + it('should return not found message when spec does not exist', async () => { + registerSpecsResources(mockServer, createPathConfig()); + + const handler = registerResourceSpy.mock.calls[1][3]; + const mockUri = { href: 'openspec://specs/nonexistent' }; + const result = await handler(mockUri, { capability: 'nonexistent' }); + + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toContain('# nonexistent'); + expect(result.contents[0].text).toContain('Specification not found.'); + }); + }); +}); diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts new file mode 100644 index 00000000..ee0ff878 --- /dev/null +++ b/test/mcp/server.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerAllResources } from '../../src/mcp/resources/index.js'; +import { registerAllTools } from '../../src/mcp/tools/index.js'; +import { registerAllPrompts } from '../../src/mcp/prompts/index.js'; +import type { PathConfig } from '../../src/mcp/utils/path-resolver.js'; + +describe('MCP Server', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + vi.resetModules(); + originalEnv = { ...process.env }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.env = originalEnv; + }); + + describe('startMcpServer', () => { + it('should export startMcpServer function', async () => { + const { startMcpServer } = await import('../../src/mcp/server.js'); + + expect(typeof startMcpServer).toBe('function'); + }); + + it('should accept McpServerOptions with debug flag', async () => { + const { startMcpServer } = await import('../../src/mcp/server.js'); + + expect(startMcpServer.length).toBe(1); + }); + }); + + describe('McpServerOptions type', () => { + it('should define debug option', async () => { + const mod = await import('../../src/mcp/server.js'); + + expect(mod).toHaveProperty('startMcpServer'); + }); + }); + + describe('server integration', () => { + it('should create MCP server with correct name and version', async () => { + const mod = await import('../../src/mcp/server.js'); + + expect(mod).toHaveProperty('startMcpServer'); + }); + + it('should register all resources with mock server', () => { + const mockServer = { + registerResource: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllResources(mockServer, mockPathConfig); + + expect(mockServer.registerResource).toHaveBeenCalled(); + }); + + it('should register all tools with mock server', () => { + const mockServer = { + registerTool: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllTools(mockServer, mockPathConfig); + + expect(mockServer.registerTool).toHaveBeenCalled(); + }); + + it('should register all prompts with mock server', () => { + const mockServer = { + registerPrompt: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllPrompts(mockServer, mockPathConfig); + + expect(mockServer.registerPrompt).toHaveBeenCalled(); + }); + }); + + describe('path configuration', () => { + let testDir: string; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-mcp-server-test-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it('should use OPENSPEC_ROOT when set', async () => { + const openspecRoot = path.join(testDir, '.openspec'); + await fs.mkdir(openspecRoot, { recursive: true }); + process.env.OPENSPEC_ROOT = openspecRoot; + + vi.resetModules(); + const { resolveOpenSpecPaths } = await import('../../src/mcp/utils/path-resolver.js'); + const config = resolveOpenSpecPaths(); + + expect(config.specsRoot).toBe(openspecRoot); + }); + + it('should use auto project root when OPENSPEC_AUTO_PROJECT_ROOT is true', async () => { + const openspecRoot = path.join(testDir, '.openspec'); + await fs.mkdir(openspecRoot, { recursive: true }); + process.env.OPENSPEC_ROOT = openspecRoot; + process.env.OPENSPEC_AUTO_PROJECT_ROOT = 'true'; + + vi.resetModules(); + const { resolveOpenSpecPaths } = await import('../../src/mcp/utils/path-resolver.js'); + const config = resolveOpenSpecPaths(); + + expect(config.isAutoProjectRoot).toBe(true); + }); + + it('should default to cwd when no OPENSPEC_ROOT is set', async () => { + delete process.env.OPENSPEC_ROOT; + delete process.env.OPENSPEC_AUTO_PROJECT_ROOT; + + vi.resetModules(); + const { resolveOpenSpecPaths } = await import('../../src/mcp/utils/path-resolver.js'); + const config = resolveOpenSpecPaths(testDir); + + expect(config.specsRoot).toBe(testDir); + expect(config.projectRoot).toBe(testDir); + expect(config.isAutoProjectRoot).toBe(false); + }); + }); +}); diff --git a/test/mcp/tools/archive.test.ts b/test/mcp/tools/archive.test.ts new file mode 100644 index 00000000..87ae1cd9 --- /dev/null +++ b/test/mcp/tools/archive.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerArchiveTool } from '../../../src/mcp/tools/archive.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Archive Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-archive-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create OpenSpec structure + await fs.mkdir(path.join(tempDir, 'openspec', 'changes', 'archive'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerArchiveTool', () => { + it('should register archive tool with correct name and description', () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerArchiveTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'archive', + expect.objectContaining({ + description: expect.stringContaining('Archive'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should archive a change successfully', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Task 1\n- [x] Task 2' + ); + + registerArchiveTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + changeName: 'test-change', + skipSpecs: true, + noValidate: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.changeName).toBe('test-change'); + expect(parsed.message).toContain('archived successfully'); + + // Verify change was moved to archive + const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive'); + const archives = await fs.readdir(archiveDir); + expect(archives.length).toBe(1); + expect(archives[0]).toContain('test-change'); + }); + + it('should return error for non-existent change', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerArchiveTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + changeName: 'non-existent', + }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('not found'); + }); + + it('should respect skipSpecs option', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change with specs + const changeDir = path.join(tempDir, 'openspec', 'changes', 'skip-specs-change'); + const changeSpecDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(changeSpecDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Done' + ); + await fs.writeFile( + path.join(changeSpecDir, 'spec.md'), + '## ADDED Requirements\n\n### Requirement: Test\n\n#### Scenario: Test\nGiven test\nWhen test\nThen test' + ); + + registerArchiveTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + changeName: 'skip-specs-change', + skipSpecs: true, + noValidate: true, + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + + // Verify main spec was NOT created + const mainSpecPath = path.join(tempDir, 'openspec', 'specs', 'alpha', 'spec.md'); + await expect(fs.access(mainSpecPath)).rejects.toThrow(); + }); + + it('should respect noValidate option', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'no-validate-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'tasks.md'), + '- [x] Task' + ); + + registerArchiveTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + changeName: 'no-validate-change', + noValidate: true, + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + }); +}); diff --git a/test/mcp/tools/edit.test.ts b/test/mcp/tools/edit.test.ts new file mode 100644 index 00000000..4df53c3a --- /dev/null +++ b/test/mcp/tools/edit.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerEditTool } from '../../../src/mcp/tools/edit.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Edit Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + tempDir = path.join(tmpBase, `openspec-mcp-edit-test-${randomUUID()}`); + await fs.mkdir(tempDir, { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + } + + describe('registerEditTool', () => { + it('should register edit tool with correct name and description', () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'edit', + expect.objectContaining({ + description: expect.stringContaining('Create or update'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should create proposal.md', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'proposal', + content: '# Change: Add Feature\n\n## Why\n\nReason here.', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.resourceType).toBe('proposal'); + expect(parsed.resourceUri).toBe('openspec://changes/add-feature/proposal'); + + const proposalPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'proposal.md' + ); + const content = await fs.readFile(proposalPath, 'utf-8'); + expect(content).toBe('# Change: Add Feature\n\n## Why\n\nReason here.'); + }); + + it('should create tasks.md', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'tasks', + content: '## 1. Implementation\n\n- [ ] 1.1 Create model', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.resourceType).toBe('tasks'); + expect(parsed.resourceUri).toBe('openspec://changes/add-feature/tasks'); + + const tasksPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'tasks.md' + ); + const content = await fs.readFile(tasksPath, 'utf-8'); + expect(content).toBe('## 1. Implementation\n\n- [ ] 1.1 Create model'); + }); + + it('should create design.md', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'design', + content: '## Context\n\nArchitectural decisions.', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.resourceType).toBe('design'); + expect(parsed.resourceUri).toBe('openspec://changes/add-feature/design'); + + const designPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'design.md' + ); + const content = await fs.readFile(designPath, 'utf-8'); + expect(content).toBe('## Context\n\nArchitectural decisions.'); + }); + + it('should create spec delta', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'spec', + capability: 'user-auth', + content: '## ADDED Requirements\n\n### Requirement: OAuth Login', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.resourceType).toBe('spec'); + expect(parsed.capability).toBe('user-auth'); + expect(parsed.resourceUri).toBe('openspec://changes/add-feature/specs/user-auth'); + + const specPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'specs', + 'user-auth', + 'spec.md' + ); + const content = await fs.readFile(specPath, 'utf-8'); + expect(content).toBe('## ADDED Requirements\n\n### Requirement: OAuth Login'); + }); + + it('should fail when spec type is used without capability', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + await expect( + registeredHandler({ + changeId: 'add-feature', + resourceType: 'spec', + content: '## ADDED Requirements', + }) + ).rejects.toThrow(); + }); + + it('should update existing files', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + // Create initial file + await registeredHandler({ + changeId: 'add-feature', + resourceType: 'proposal', + content: 'Initial content', + }); + + // Update the file + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'proposal', + content: 'Updated content', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + + const proposalPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'proposal.md' + ); + const content = await fs.readFile(proposalPath, 'utf-8'); + expect(content).toBe('Updated content'); + }); + + it('should create multiple spec deltas for different capabilities', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + await registeredHandler({ + changeId: 'add-feature', + resourceType: 'spec', + capability: 'user-auth', + content: '## ADDED Requirements\n\n### Requirement: OAuth', + }); + + await registeredHandler({ + changeId: 'add-feature', + resourceType: 'spec', + capability: 'notifications', + content: '## ADDED Requirements\n\n### Requirement: Email Notifications', + }); + + const authPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'specs', + 'user-auth', + 'spec.md' + ); + const notifyPath = path.join( + tempDir, + 'openspec', + 'changes', + 'add-feature', + 'specs', + 'notifications', + 'spec.md' + ); + + const authContent = await fs.readFile(authPath, 'utf-8'); + const notifyContent = await fs.readFile(notifyPath, 'utf-8'); + + expect(authContent).toContain('OAuth'); + expect(notifyContent).toContain('Email Notifications'); + }); + + it('should return suggested next actions for proposal', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'proposal', + content: '# Proposal', + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.suggestedNextActions).toContain( + "Create tasks using edit tool with resourceType='tasks'" + ); + }); + + it('should return suggested next actions for spec', async () => { + registerEditTool(mockServer as unknown as McpServer, createPathConfig()); + + const result = await registeredHandler({ + changeId: 'add-feature', + resourceType: 'spec', + capability: 'user-auth', + content: '## ADDED Requirements', + }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.suggestedNextActions).toContainEqual( + expect.stringContaining('openspec://changes/add-feature/specs/user-auth') + ); + }); + }); +}); diff --git a/test/mcp/tools/index.test.ts b/test/mcp/tools/index.test.ts new file mode 100644 index 00000000..f15592ed --- /dev/null +++ b/test/mcp/tools/index.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerAllTools } from '../../../src/mcp/tools/index.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Tools', () => { + describe('registerAllTools', () => { + it('should export registerAllTools function', () => { + expect(typeof registerAllTools).toBe('function'); + }); + + it('should register all 7 tools with the MCP server', () => { + const mockServer = { + registerTool: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/test/project', + projectRoot: '/test/project', + isAutoProjectRoot: false, + }; + + registerAllTools(mockServer, mockPathConfig); + + // Should register 7 tools: init, list, show, validate, archive, update_project_context, edit + expect(mockServer.registerTool).toHaveBeenCalledTimes(7); + + // Verify tool names + const toolNames = (mockServer.registerTool as ReturnType).mock.calls.map( + (call: any[]) => call[0] + ); + expect(toolNames).toContain('init'); + expect(toolNames).toContain('list'); + expect(toolNames).toContain('show'); + expect(toolNames).toContain('validate'); + expect(toolNames).toContain('archive'); + expect(toolNames).toContain('update_project_context'); + expect(toolNames).toContain('edit'); + }); + + it('should pass pathConfig to each tool registration', () => { + const mockServer = { + registerTool: vi.fn(), + } as unknown as McpServer; + + const mockPathConfig: PathConfig = { + specsRoot: '/custom/path', + projectRoot: '/custom/path', + isAutoProjectRoot: true, + }; + + registerAllTools(mockServer, mockPathConfig); + + // Each tool registration receives the server and pathConfig + expect(mockServer.registerTool).toHaveBeenCalledTimes(7); + }); + }); +}); diff --git a/test/mcp/tools/init.test.ts b/test/mcp/tools/init.test.ts new file mode 100644 index 00000000..331e1dfb --- /dev/null +++ b/test/mcp/tools/init.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Init Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-init-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + mockServer = { + registerTool: vi.fn(), + }; + + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerInitTool', () => { + it('should register init tool with correct name and description', async () => { + const { registerInitTool } = await import('../../../src/mcp/tools/init.js'); + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerInitTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'init', + expect.objectContaining({ + description: expect.stringContaining('Initialize OpenSpec'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should not define tools parameter in input schema', async () => { + const { registerInitTool } = await import('../../../src/mcp/tools/init.js'); + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerInitTool(mockServer as unknown as McpServer, pathConfig); + + const toolCall = mockServer.registerTool.mock.calls[0]; + const config = toolCall[1]; + const schema = config.inputSchema; + + // Verify the schema does NOT have a tools parameter + expect(schema).not.toHaveProperty('tools'); + }); + + it('should accept pathConfig with auto project root', async () => { + const { registerInitTool } = await import('../../../src/mcp/tools/init.js'); + const customPath = path.join(tempDir, 'custom'); + await fs.mkdir(customPath, { recursive: true }); + + const pathConfig: PathConfig = { + specsRoot: customPath, + projectRoot: tempDir, + isAutoProjectRoot: true, + }; + + registerInitTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/mcp/tools/list.test.ts b/test/mcp/tools/list.test.ts new file mode 100644 index 00000000..9da32c97 --- /dev/null +++ b/test/mcp/tools/list.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerListTool } from '../../../src/mcp/tools/list.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP List Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-list-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create OpenSpec structure + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerListTool', () => { + it('should register list tool with correct name and description', () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerListTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'list', + expect.objectContaining({ + description: expect.stringContaining('List active changes'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should list changes mode by default', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Task 1'); + + registerListTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({}); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.mode).toBe('changes'); + }); + + it('should list specs when mode is specs', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a spec + const specDir = path.join(tempDir, 'openspec', 'specs', 'test-spec'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile(path.join(specDir, 'spec.md'), '# Test Spec\n\n## Purpose\nTest purpose.\n\n## Requirements\n'); + + registerListTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ mode: 'specs' }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.mode).toBe('specs'); + }); + + it('should handle empty directories gracefully', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerListTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ mode: 'changes' }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should return error when openspec directory does not exist', async () => { + const emptyDir = path.join(tempDir, 'empty'); + await fs.mkdir(emptyDir, { recursive: true }); + + const pathConfig: PathConfig = { + specsRoot: emptyDir, + projectRoot: emptyDir, + isAutoProjectRoot: false, + }; + + registerListTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ mode: 'changes' }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + }); + }); +}); diff --git a/test/mcp/tools/project-context.test.ts b/test/mcp/tools/project-context.test.ts new file mode 100644 index 00000000..2c6ab34d --- /dev/null +++ b/test/mcp/tools/project-context.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerProjectContextTool } from '../../../src/mcp/tools/project-context.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Project Context Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-project-context-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerProjectContextTool', () => { + it('should register update_project_context tool with correct name and description', () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'update_project_context', + expect.objectContaining({ + description: expect.stringContaining('Update'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should create project.md with full content', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + content: '# Project Context\n\nThis is my project context.', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.message).toContain('updated successfully'); + + // Verify file was created + const projectPath = path.join(tempDir, 'openspec', 'project.md'); + const content = await fs.readFile(projectPath, 'utf-8'); + expect(content).toBe('# Project Context\n\nThis is my project context.'); + }); + + it('should update specific sections', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create initial project.md + const openspecDir = path.join(tempDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + await fs.writeFile( + path.join(openspecDir, 'project.md'), + '# Project\n\n## Overview\nOriginal overview.\n\n## Tech Stack\nOriginal stack.' + ); + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + sections: { + 'Overview': 'Updated overview content.', + }, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.updatedSections).toContain('Overview'); + + // Verify file was updated + const projectPath = path.join(tempDir, 'openspec', 'project.md'); + const content = await fs.readFile(projectPath, 'utf-8'); + expect(content).toContain('Updated overview content.'); + expect(content).toContain('## Tech Stack'); + }); + + it('should add new sections when they do not exist', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create initial project.md + const openspecDir = path.join(tempDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + await fs.writeFile( + path.join(openspecDir, 'project.md'), + '# Project\n\n## Overview\nOriginal overview.' + ); + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + sections: { + 'New Section': 'Content for new section.', + }, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + + // Verify new section was added + const projectPath = path.join(tempDir, 'openspec', 'project.md'); + const content = await fs.readFile(projectPath, 'utf-8'); + expect(content).toContain('## New Section'); + expect(content).toContain('Content for new section.'); + }); + + it('should create project.md when updating sections on empty file', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + sections: { + 'Overview': 'New project overview.', + 'Tech Stack': 'Node.js, TypeScript', + }, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + + // Verify file was created with sections + const projectPath = path.join(tempDir, 'openspec', 'project.md'); + const content = await fs.readFile(projectPath, 'utf-8'); + expect(content).toContain('## Overview'); + expect(content).toContain('New project overview.'); + expect(content).toContain('## Tech Stack'); + expect(content).toContain('Node.js, TypeScript'); + }); + + it('should return error when neither content nor sections provided', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + // The Zod validation should fail + await expect(registeredHandler({})).rejects.toThrow(); + }); + + it('should handle multiple sections update', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerProjectContextTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + sections: { + 'Overview': 'Project overview.', + 'Architecture': 'Microservices architecture.', + 'Dependencies': 'Express, PostgreSQL', + }, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.updatedSections).toEqual(['Overview', 'Architecture', 'Dependencies']); + + // Verify all sections were added + const projectPath = path.join(tempDir, 'openspec', 'project.md'); + const content = await fs.readFile(projectPath, 'utf-8'); + expect(content).toContain('## Overview'); + expect(content).toContain('## Architecture'); + expect(content).toContain('## Dependencies'); + }); + }); +}); diff --git a/test/mcp/tools/show.test.ts b/test/mcp/tools/show.test.ts new file mode 100644 index 00000000..2d81ae1c --- /dev/null +++ b/test/mcp/tools/show.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerShowTool } from '../../../src/mcp/tools/show.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Show Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-show-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create OpenSpec structure + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerShowTool', () => { + it('should register show tool with correct name and description', () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerShowTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'show', + expect.objectContaining({ + description: expect.stringContaining('Show details'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should show a change', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'test-change'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Test Change\n\n## Why\nTest reason.\n\n## What Changes\n- **alpha:** Test change' + ); + + registerShowTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'test-change', + type: 'change', + json: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.itemName).toBe('test-change'); + expect(parsed.type).toBe('change'); + }); + + it('should show a spec', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a spec + const specDir = path.join(tempDir, 'openspec', 'specs', 'test-spec'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + '# Test Spec\n\n## Purpose\nTest purpose.\n\n## Requirements\n\n### Requirement: Test requirement\n\n#### Scenario: Test scenario\nGiven a test condition\nWhen an action occurs\nThen expected result happens' + ); + + registerShowTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'test-spec', + type: 'spec', + json: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.itemName).toBe('test-spec'); + expect(parsed.type).toBe('spec'); + }); + + it('should auto-detect type when not provided', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change + const changeDir = path.join(tempDir, 'openspec', 'changes', 'auto-detect'); + await fs.mkdir(changeDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Auto Detect Change\n\n## Why\nTest reason.\n\n## What Changes\n- **alpha:** Test change' + ); + + registerShowTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'auto-detect', + json: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + expect(parsed.type).toBe('change'); + }); + + it('should return error for non-existent item', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerShowTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'non-existent', + json: true, + }); + + expect(result.isError).toBe(true); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(false); + expect(parsed.error).toContain('not found'); + }); + }); +}); diff --git a/test/mcp/tools/validate.test.ts b/test/mcp/tools/validate.test.ts new file mode 100644 index 00000000..6c3a8e36 --- /dev/null +++ b/test/mcp/tools/validate.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { registerValidateTool } from '../../../src/mcp/tools/validate.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('MCP Validate Tool', () => { + let tempDir: string; + let mockServer: { + registerTool: ReturnType; + }; + let registeredHandler: Function; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-mcp-validate-test-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + + // Create OpenSpec structure + await fs.mkdir(path.join(tempDir, 'openspec', 'changes'), { recursive: true }); + await fs.mkdir(path.join(tempDir, 'openspec', 'specs'), { recursive: true }); + + mockServer = { + registerTool: vi.fn((name, config, handler) => { + registeredHandler = handler; + }), + }; + + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('registerValidateTool', () => { + it('should register validate tool with correct name and description', () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + expect(mockServer.registerTool).toHaveBeenCalledTimes(1); + expect(mockServer.registerTool).toHaveBeenCalledWith( + 'validate', + expect.objectContaining({ + description: expect.stringContaining('Validate'), + inputSchema: expect.any(Object), + }), + expect.any(Function) + ); + }); + + it('should validate a spec', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a valid spec + const specDir = path.join(tempDir, 'openspec', 'specs', 'valid-spec'); + await fs.mkdir(specDir, { recursive: true }); + await fs.writeFile( + path.join(specDir, 'spec.md'), + `# Valid Spec + +## Purpose +This is a test specification. + +## Requirements + +### Requirement: Test requirement SHALL pass validation + +#### Scenario: Validation succeeds +- **GIVEN** a valid spec +- **WHEN** validation runs +- **THEN** it passes` + ); + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'valid-spec', + type: 'spec', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should validate a change with deltas', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + // Create a change with delta specs + const changeDir = path.join(tempDir, 'openspec', 'changes', 'valid-change'); + const deltaDir = path.join(changeDir, 'specs', 'alpha'); + await fs.mkdir(deltaDir, { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'proposal.md'), + '# Valid Change\n\n## Why\nTest reason.\n\n## What Changes\n- **alpha:** Test change' + ); + await fs.writeFile( + path.join(deltaDir, 'spec.md'), + `## ADDED Requirements + +### Requirement: New feature SHALL work correctly + +#### Scenario: Feature works +- **GIVEN** the new feature +- **WHEN** it is used +- **THEN** it works correctly` + ); + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + itemName: 'valid-change', + type: 'change', + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should validate all changes with --changes flag', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + changes: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should validate all specs with --specs flag', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + specs: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should validate all with --all flag', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + all: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + + it('should support strict mode', async () => { + const pathConfig: PathConfig = { + specsRoot: tempDir, + projectRoot: tempDir, + isAutoProjectRoot: false, + }; + + registerValidateTool(mockServer as unknown as McpServer, pathConfig); + + const result = await registeredHandler({ + all: true, + strict: true, + }); + + expect(result.content).toBeDefined(); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.success).toBe(true); + }); + }); +}); diff --git a/test/mcp/utils/context-loader.test.ts b/test/mcp/utils/context-loader.test.ts new file mode 100644 index 00000000..229d2216 --- /dev/null +++ b/test/mcp/utils/context-loader.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { + loadAgentsMarkdown, + loadProjectMarkdown, +} from '../../../src/mcp/utils/context-loader.js'; +import type { PathConfig } from '../../../src/mcp/utils/path-resolver.js'; + +describe('context-loader', () => { + let testDir: string; + let openspecDir: string; + + beforeEach(async () => { + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-context-test-${randomUUID()}`); + openspecDir = path.join(testDir, 'openspec'); + await fs.mkdir(openspecDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + function createPathConfig(): PathConfig { + return { + specsRoot: testDir, + projectRoot: testDir, + isAutoProjectRoot: false, + }; + } + + describe('loadAgentsMarkdown', () => { + it('should return file content when AGENTS.md exists', async () => { + const content = '# Custom Agents Instructions\n\nTest content here.'; + await fs.writeFile(path.join(openspecDir, 'AGENTS.md'), content); + + const result = await loadAgentsMarkdown(createPathConfig()); + + expect(result).toBe(content); + }); + + it('should return default template when AGENTS.md does not exist', async () => { + const result = await loadAgentsMarkdown(createPathConfig()); + + expect(result).toContain('# OpenSpec Instructions'); + expect(result).toContain('## Quick Start'); + expect(result).toContain('## Three-Stage Workflow'); + }); + + it('should return default template when openspec directory does not exist', async () => { + await fs.rm(openspecDir, { recursive: true, force: true }); + + const result = await loadAgentsMarkdown(createPathConfig()); + + expect(result).toContain('# OpenSpec Instructions'); + }); + }); + + describe('loadProjectMarkdown', () => { + it('should return file content when project.md exists', async () => { + const content = '# My Project\n\nCustom project details.'; + await fs.writeFile(path.join(openspecDir, 'project.md'), content); + + const result = await loadProjectMarkdown(createPathConfig()); + + expect(result).toBe(content); + }); + + it('should return default template when project.md does not exist', async () => { + const result = await loadProjectMarkdown(createPathConfig()); + + expect(result).toContain('# Project Context'); + expect(result).toContain('## Purpose'); + expect(result).toContain('## Tech Stack'); + expect(result).toContain('## Project Conventions'); + }); + + it('should return default template when openspec directory does not exist', async () => { + await fs.rm(openspecDir, { recursive: true, force: true }); + + const result = await loadProjectMarkdown(createPathConfig()); + + expect(result).toContain('# Project Context'); + }); + }); +}); diff --git a/test/mcp/utils/path-resolver.test.ts b/test/mcp/utils/path-resolver.test.ts new file mode 100644 index 00000000..0c462774 --- /dev/null +++ b/test/mcp/utils/path-resolver.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { + resolveOpenSpecPaths, + getOpenSpecDir, + getChangePath, + getSpecPath, + getSpecsDir, + getArchiveDir, + getChangesDir, + getAgentsPath, + getProjectPath, + getChangeProposalPath, + getChangeTasksPath, + getChangeDesignPath, + getChangeSpecsDir, + getChangeSpecDeltaPath, + type PathConfig, +} from '../../../src/mcp/utils/path-resolver.js'; + +describe('path-resolver', () => { + let testDir: string; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + + beforeEach(async () => { + // Use realpath to resolve symlinks (e.g., /var -> /private/var on macOS) + const tmpBase = await fs.realpath(os.tmpdir()); + testDir = path.join(tmpBase, `openspec-mcp-test-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + }); + + afterEach(async () => { + process.env = originalEnv; + process.chdir(originalCwd); + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('resolveOpenSpecPaths', () => { + describe('without OPENSPEC_ROOT', () => { + beforeEach(() => { + delete process.env.OPENSPEC_ROOT; + delete process.env.OPENSPEC_AUTO_PROJECT_ROOT; + }); + + it('should use project path as specs root when provided', () => { + const result = resolveOpenSpecPaths(testDir); + + expect(result.specsRoot).toBe(testDir); + expect(result.projectRoot).toBe(testDir); + expect(result.isAutoProjectRoot).toBe(false); + }); + + it('should use cwd as specs root when no path provided', async () => { + process.chdir(testDir); + const result = resolveOpenSpecPaths(); + const realTestDir = await fs.realpath(testDir); + + expect(result.specsRoot).toBe(realTestDir); + expect(result.projectRoot).toBe(realTestDir); + expect(result.isAutoProjectRoot).toBe(false); + }); + + it('should resolve relative project paths', async () => { + process.chdir(testDir); + const subdir = path.join(testDir, 'subproject'); + const result = resolveOpenSpecPaths('./subproject'); + const realSubdir = await fs.realpath(testDir).then((d) => + path.join(d, 'subproject') + ); + + expect(result.specsRoot).toBe(realSubdir); + expect(result.projectRoot).toBe(realSubdir); + }); + }); + + describe('with OPENSPEC_ROOT (fixed root mode)', () => { + let openspecRoot: string; + + beforeEach(async () => { + openspecRoot = path.join(testDir, '.openspec'); + await fs.mkdir(openspecRoot, { recursive: true }); + process.env.OPENSPEC_ROOT = openspecRoot; + delete process.env.OPENSPEC_AUTO_PROJECT_ROOT; + }); + + it('should use OPENSPEC_ROOT as specs root', () => { + const projectPath = path.join(testDir, 'myproject'); + const result = resolveOpenSpecPaths(projectPath); + + expect(result.specsRoot).toBe(openspecRoot); + expect(result.projectRoot).toBe(projectPath); + expect(result.isAutoProjectRoot).toBe(false); + }); + + it('should resolve relative OPENSPEC_ROOT', async () => { + process.chdir(testDir); + process.env.OPENSPEC_ROOT = '.openspec'; + const result = resolveOpenSpecPaths(testDir); + const realOpenspecRoot = await fs.realpath(openspecRoot); + + expect(result.specsRoot).toBe(realOpenspecRoot); + }); + }); + + describe('with OPENSPEC_AUTO_PROJECT_ROOT', () => { + let openspecRoot: string; + + beforeEach(async () => { + openspecRoot = path.join(testDir, '.openspec'); + await fs.mkdir(openspecRoot, { recursive: true }); + process.env.OPENSPEC_ROOT = openspecRoot; + process.env.OPENSPEC_AUTO_PROJECT_ROOT = 'true'; + }); + + it('should create project-specific directory under OPENSPEC_ROOT', async () => { + const projectPath = path.join(os.homedir(), 'code', 'myproject'); + const result = resolveOpenSpecPaths(projectPath); + + expect(result.isAutoProjectRoot).toBe(true); + expect(result.projectRoot).toBe(projectPath); + expect(result.specsRoot).toContain(openspecRoot); + expect(result.specsRoot).toContain('myproject'); + }); + + it('should use basename for paths outside home directory', () => { + const projectPath = '/var/projects/myapp'; + const result = resolveOpenSpecPaths(projectPath); + + expect(result.specsRoot).toBe(path.join(openspecRoot, 'myapp')); + }); + + it('should strip home prefix for paths inside home directory', () => { + const homeDir = os.homedir(); + const projectPath = path.join(homeDir, 'workspace', 'project'); + const result = resolveOpenSpecPaths(projectPath); + + expect(result.specsRoot).toBe( + path.join(openspecRoot, 'workspace', 'project') + ); + }); + }); + }); + + describe('getOpenSpecDir', () => { + it('should return openspec subdirectory of specsRoot', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getOpenSpecDir(config); + + expect(result).toBe('/home/user/project/openspec'); + }); + }); + + describe('getChangePath', () => { + it('should return path to change directory', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangePath(config, 'add-mcp-server'); + + expect(result).toBe('/home/user/project/openspec/changes/add-mcp-server'); + }); + }); + + describe('getSpecPath', () => { + it('should return path to spec file', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getSpecPath(config, 'user-auth'); + + expect(result).toBe( + '/home/user/project/openspec/specs/user-auth/spec.md' + ); + }); + }); + + describe('getSpecsDir', () => { + it('should return specs directory path', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getSpecsDir(config); + + expect(result).toBe('/home/user/project/openspec/specs'); + }); + + it('should work with auto project root mode', () => { + const config: PathConfig = { + specsRoot: '/home/user/.openspec/code/myapp', + projectRoot: '/home/user/code/myapp', + isAutoProjectRoot: true, + }; + + const result = getSpecsDir(config); + + expect(result).toBe('/home/user/.openspec/code/myapp/openspec/specs'); + }); + }); + + describe('getArchiveDir', () => { + it('should return archive directory path', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getArchiveDir(config); + + expect(result).toBe('/home/user/project/openspec/changes/archive'); + }); + }); + + describe('getChangesDir', () => { + it('should return changes directory path', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangesDir(config); + + expect(result).toBe('/home/user/project/openspec/changes'); + }); + }); + + describe('getAgentsPath', () => { + it('should return AGENTS.md file path', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getAgentsPath(config); + + expect(result).toBe('/home/user/project/openspec/AGENTS.md'); + }); + }); + + describe('getProjectPath', () => { + it('should return project.md file path', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getProjectPath(config); + + expect(result).toBe('/home/user/project/openspec/project.md'); + }); + }); + + describe('getChangeProposalPath', () => { + it('should return proposal.md path for a change', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeProposalPath(config, 'add-feature'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/proposal.md' + ); + }); + }); + + describe('getChangeTasksPath', () => { + it('should return tasks.md path for a change', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeTasksPath(config, 'add-feature'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/tasks.md' + ); + }); + }); + + describe('getChangeDesignPath', () => { + it('should return design.md path for a change', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeDesignPath(config, 'add-feature'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/design.md' + ); + }); + }); + + describe('getChangeSpecsDir', () => { + it('should return specs directory path within a change', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeSpecsDir(config, 'add-feature'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/specs' + ); + }); + }); + + describe('getChangeSpecDeltaPath', () => { + it('should return spec delta path for a capability within a change', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeSpecDeltaPath(config, 'add-feature', 'auth'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/specs/auth/spec.md' + ); + }); + + it('should handle nested capability names', () => { + const config: PathConfig = { + specsRoot: '/home/user/project', + projectRoot: '/home/user/project', + isAutoProjectRoot: false, + }; + + const result = getChangeSpecDeltaPath(config, 'add-feature', 'user-management'); + + expect(result).toBe( + '/home/user/project/openspec/changes/add-feature/specs/user-management/spec.md' + ); + }); + }); +});