diff --git a/.changeset/mcp-server-integration.md b/.changeset/mcp-server-integration.md new file mode 100644 index 00000000..4d1f331a --- /dev/null +++ b/.changeset/mcp-server-integration.md @@ -0,0 +1,5 @@ +--- +"@sanring/cli": minor +--- + +New `sanring mcp` command: starts an MCP server over stdio so AI coding agents (Claude Code, Cursor, Windsurf) can query and install components directly, without shelling out. Exposes five tools — `list_components`, `search_components`, `get_component_info`, `plan_component_install` (dry-run preview), and `add_component`. diff --git a/.vscode/mcp.json b/.vscode/mcp.json index 956af8c6..44f24cbf 100644 --- a/.vscode/mcp.json +++ b/.vscode/mcp.json @@ -4,6 +4,10 @@ "angular-cli": { "command": "npx", "args": ["-y", "@angular/cli", "mcp"] + }, + "sanring": { + "command": "node", + "args": ["${workspaceFolder}/packages/cli/dist/index.js", "mcp"] } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 2678da1f..8383a541 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -162,6 +162,54 @@ npx @sanring/cli@latest list --- +### `mcp` + +Start the Sanring UI MCP server over stdio so AI coding agents can inspect the +registry and install components without shelling out manually. + +```bash +npx @sanring/cli@latest mcp +``` + +The server exposes five tools: + +| Tool | Description | +|---|---| +| `list_components` | List every available component with its description | +| `search_components` | Search components by name or description | +| `get_component_info` | Show files, auto-installed component dependencies, shared utilities, and peer dependencies | +| `plan_component_install` | Preview files, component deps, and peer packages that would be installed — without modifying the project | +| `add_component` | Run `sanring add --yes` in the target Angular project (call `plan_component_install` first to preview) | + +Claude Code example: + +```json +{ + "mcpServers": { + "sanring": { + "command": "npx", + "args": ["@sanring/cli@latest", "mcp"] + } + } +} +``` + +For local development before publishing, build the CLI first and point the +agent at the compiled entry: + +```json +{ + "mcpServers": { + "sanring": { + "command": "node", + "args": ["/absolute/path/to/sanring-workspace/packages/cli/dist/index.js", "mcp"] + } + } +} +``` + +--- + ## Requirements - Node.js >= 18 diff --git a/packages/cli/package.json b/packages/cli/package.json index 20c3a770..b6297d5f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,7 @@ "test": "vitest run" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "commander": "^12.0.0", "diff": "^7.0.0", "ora": "^9.4.1", diff --git a/packages/cli/src/__tests__/registry-fixture.ts b/packages/cli/src/__tests__/registry-fixture.ts index 3f96446d..c65ce5c9 100644 --- a/packages/cli/src/__tests__/registry-fixture.ts +++ b/packages/cli/src/__tests__/registry-fixture.ts @@ -4,6 +4,7 @@ import type { Registry } from '../registry.js'; export interface RegistryFixtureContent { utils?: string; + utilsPeerDependencies?: Record; theme?: string; widget?: string; /** Second file added to the widget component — simulates a registry adding a new file post-install. */ @@ -21,7 +22,12 @@ export function writeRegistryFixture(dir: string, content: RegistryFixtureConten const shared: Registry['shared'] = []; if (content.utils !== undefined) { writeFileSync(join(dir, 'shared', 'utils.ts'), content.utils, 'utf-8'); - shared.push({ name: 'utils', description: 'fixture utils', file: 'shared/utils.ts' }); + shared.push({ + name: 'utils', + description: 'fixture utils', + file: 'shared/utils.ts', + peerDependencies: content.utilsPeerDependencies, + }); } if (content.theme !== undefined) { writeFileSync(join(dir, 'shared', 'theme.css'), content.theme, 'utf-8'); diff --git a/packages/cli/src/commands/mcp.e2e.test.ts b/packages/cli/src/commands/mcp.e2e.test.ts new file mode 100644 index 00000000..b135fe77 --- /dev/null +++ b/packages/cli/src/commands/mcp.e2e.test.ts @@ -0,0 +1,84 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { writeRegistryFixture } from '../__tests__/registry-fixture.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const cliRoot = join(__dirname, '../..'); +const distIndex = join(cliRoot, 'dist/index.js'); + +describe('mcp server e2e (stdio)', () => { + let registryDir: string; + let projectDir: string; + let client: Client | undefined; + let transport: StdioClientTransport | undefined; + + beforeAll(() => { + execSync('npm run build', { cwd: cliRoot, stdio: 'pipe' }); + }, 60_000); + + beforeEach(() => { + registryDir = mkdtempSync(join(tmpdir(), 'sanring-mcp-e2e-reg-')); + projectDir = mkdtempSync(join(tmpdir(), 'sanring-mcp-e2e-proj-')); + writeFileSync(join(projectDir, 'angular.json'), '{}', 'utf-8'); + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + widget: 'export const widget = 1;\n', + }); + }); + + afterEach(async () => { + await client?.close(); + await transport?.close(); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(registryDir, { recursive: true, force: true }); + }); + + it('add_component writes component files into the Angular project', async () => { + transport = new StdioClientTransport({ + command: process.execPath, + args: [distIndex, 'mcp', '--registry', registryDir], + }); + client = new Client({ name: 'sanring-e2e', version: '0.0.0' }, { capabilities: {} }); + await client.connect(transport); + + const result = await client.callTool({ + name: 'add_component', + arguments: { name: 'widget', cwd: projectDir }, + }); + + const text = (result.content as Array<{ type: string; text?: string }>) + .filter((c) => c.type === 'text') + .map((c) => c.text ?? '') + .join('\n'); + + expect(text).toContain('Successfully added "widget"'); + expect(existsSync(join(projectDir, 'src/app/components/ui/widget/index.ts'))).toBe(true); + }); + + it('returns isError when cwd has no angular.json', async () => { + transport = new StdioClientTransport({ + command: process.execPath, + args: [distIndex, 'mcp', '--registry', registryDir], + }); + client = new Client({ name: 'sanring-e2e', version: '0.0.0' }, { capabilities: {} }); + await client.connect(transport); + + const result = await client.callTool({ + name: 'add_component', + arguments: { name: 'widget', cwd: '/tmp/not-an-angular-project' }, + }); + + expect((result as { isError?: boolean }).isError).toBe(true); + const text = (result.content as Array<{ type: string; text?: string }>) + .filter((c) => c.type === 'text') + .map((c) => c.text ?? '') + .join('\n'); + expect(text).toContain('angular.json not found'); + }); +}); diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 00000000..1571bae0 --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -0,0 +1,171 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { writeRegistryFixture } from '../__tests__/registry-fixture.js'; +import { createMcpServer, type AddComponentToolInput } from './mcp.js'; + +interface TextToolResult { + content: Array<{ type: string; text?: string }>; +} + +function hasTextToolContent(result: unknown): result is TextToolResult { + return ( + typeof result === 'object' && + result !== null && + 'content' in result && + Array.isArray((result as { content?: unknown }).content) + ); +} + +function textContent(result: unknown): string { + if (!hasTextToolContent(result)) return ''; + return result.content + .map((item) => (item.type === 'text' ? item.text : '')) + .filter(Boolean) + .join('\n'); +} + +describe('mcp server', () => { + let registryDir: string; + let projectDir: string; + let client: Client | undefined; + let clientTransport: InMemoryTransport | undefined; + let serverTransport: InMemoryTransport | undefined; + + beforeEach(async () => { + registryDir = mkdtempSync(join(tmpdir(), 'sanring-cli-mcp-registry-')); + projectDir = mkdtempSync(join(tmpdir(), 'sanring-cli-mcp-project-')); + writeFileSync(join(projectDir, 'angular.json'), '{}', 'utf-8'); + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + utilsPeerDependencies: { clsx: '^2.0.0' }, + widget: 'export const widget = 1;\n', + }); + }); + + afterEach(async () => { + await client?.close(); + await clientTransport?.close(); + await serverTransport?.close(); + rmSync(projectDir, { recursive: true, force: true }); + rmSync(registryDir, { recursive: true, force: true }); + }); + + async function connect(addComponent?: (input: AddComponentToolInput) => Promise<{ + ok: boolean; + output: string; + }>): Promise { + const server = createMcpServer({ registryUrl: registryDir, addComponent }); + client = new Client( + { name: 'sanring-cli-mcp-test', version: '0.0.0' }, + { capabilities: {} }, + ); + [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return client; + } + + it('lists the tools exposed to AI agents', async () => { + const testClient = await connect(); + + const result = await testClient.listTools(); + + expect(result.tools.map((tool) => tool.name)).toEqual([ + 'list_components', + 'search_components', + 'get_component_info', + 'plan_component_install', + 'add_component', + ]); + }); + + it('searches and describes components through MCP calls', async () => { + const testClient = await connect(); + + const searchResult = await testClient.callTool({ + name: 'search_components', + arguments: { query: 'fixture' }, + }); + const detailResult = await testClient.callTool({ + name: 'get_component_info', + arguments: { name: 'widget' }, + }); + + expect(textContent(searchResult)).toContain('widget'); + expect(textContent(detailResult)).toContain('Files (1):'); + expect(textContent(detailResult)).toContain('widget/index.ts'); + expect(textContent(detailResult)).toContain('Shared utilities: utils'); + expect(textContent(detailResult)).toContain('clsx@^2.0.0'); + }); + + it('plan_component_install returns files and peer deps without modifying project', async () => { + const testClient = await connect(); + + const result = await testClient.callTool({ + name: 'plan_component_install', + arguments: { name: 'widget' }, + }); + + const text = textContent(result); + expect(text).toContain('Plan for: sanring add widget'); + expect(text).toContain('widget/index.ts'); + expect(text).toContain('clsx@^2.0.0'); + expect(text).toContain('Run add_component to apply.'); + expect((result as { isError?: boolean }).isError).toBeUndefined(); + }); + + it('returns isError when a component name is not found', async () => { + const testClient = await connect(); + + const detailResult = await testClient.callTool({ + name: 'get_component_info', + arguments: { name: 'does-not-exist' }, + }); + const planResult = await testClient.callTool({ + name: 'plan_component_install', + arguments: { name: 'does-not-exist' }, + }); + + for (const result of [detailResult, planResult]) { + expect((result as { isError?: boolean }).isError).toBe(true); + expect(textContent(result)).toContain('not found'); + } + }); + + it('returns isError for missing or empty string arguments', async () => { + const testClient = await connect(); + + const missingQuery = await testClient.callTool({ name: 'search_components', arguments: {} }); + const emptyQuery = await testClient.callTool({ name: 'search_components', arguments: { query: ' ' } }); + const missingName = await testClient.callTool({ name: 'get_component_info', arguments: {} }); + const missingCwd = await testClient.callTool({ name: 'add_component', arguments: { name: 'widget' } }); + + for (const result of [missingQuery, emptyQuery, missingName, missingCwd]) { + expect((result as { isError?: boolean }).isError).toBe(true); + expect(textContent(result)).toContain('must be a non-empty string'); + } + }); + + it('runs add_component through the MCP tool boundary', async () => { + const calls: AddComponentToolInput[] = []; + const testClient = await connect(async (input) => { + calls.push(input); + writeFileSync(join(input.cwd, 'sanring-added.txt'), input.name, 'utf-8'); + return { ok: true, output: 'installed widget' }; + }); + + const result = await testClient.callTool({ + name: 'add_component', + arguments: { name: 'widget', cwd: projectDir }, + }); + + expect(calls).toEqual([{ name: 'widget', cwd: projectDir }]); + expect(existsSync(join(projectDir, 'sanring-added.txt'))).toBe(true); + expect(textContent(result)).toContain('Successfully added "widget"'); + expect(textContent(result)).toContain('installed widget'); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..ce885fc7 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,414 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { spawn } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Command } from 'commander'; +import { + createRegistryIndex, + fetchRegistry, + type Registry, + type RegistryComponent, +} from '../registry.js'; +import { resolveInstallSet, collectPeerDeps } from './add.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function getCliVersion(): string { + try { + // __dirname is dist/commands/ at runtime; package.json is two levels up + const pkg = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf-8')) as { + version: string; + }; + return pkg.version; + } catch { + return 'unknown'; + } +} + +function formatComponentDetail( + component: RegistryComponent, + registry: Registry, +): string { + const registryIndex = createRegistryIndex(registry); + const { toInstall, autoAdded } = resolveInstallSet([component.name], registryIndex); + const peerDeps = collectPeerDeps(toInstall, registryIndex); + + const lines: string[] = [ + `${component.name} — ${component.description}`, + '', + `Files (${component.files.length}):`, + ...component.files.map((f) => ` ${f}`), + ]; + + if (autoAdded.length > 0) { + lines.push('', `Auto-installed component dependencies: ${autoAdded.join(', ')}`); + } + if (component.sharedDeps?.length) { + lines.push(`Shared utilities: ${component.sharedDeps.join(', ')}`); + } + if (Object.keys(peerDeps).length > 0) { + lines.push('', 'Peer dependencies (installed into your Angular project):'); + for (const [pkg, ver] of Object.entries(peerDeps)) { + lines.push(` ${pkg}@${ver}`); + } + } + + return lines.join('\n'); +} + +type McpErrorContent = { isError: true; content: [{ type: 'text'; text: string }] }; + +function requireStrings( + args: unknown, + fields: T[], +): { values: Record } | McpErrorContent { + const obj = (args ?? {}) as Record; + for (const field of fields) { + const val = obj[field]; + if (typeof val !== 'string' || val.trim() === '') { + return { + isError: true, + content: [{ type: 'text' as const, text: `Missing or invalid argument: "${field}" must be a non-empty string.` }], + }; + } + } + return { values: Object.fromEntries(fields.map((f) => [f, (obj[f] as string).trim()])) as Record }; +} + +export interface AddComponentToolInput { + name: string; + cwd: string; +} + +export interface AddComponentToolResult { + ok: boolean; + output: string; + exitCode?: number | null; + errorMessage?: string; +} + +export interface CreateMcpServerOptions { + registryUrl?: string; + addComponent?: ( + input: AddComponentToolInput, + ) => AddComponentToolResult | Promise; +} + +export function createMcpServer(options: CreateMcpServerOptions = {}): Server { + const registryUrl = options.registryUrl; + + const addComponent = options.addComponent ?? (({ name: componentName, cwd }: AddComponentToolInput): Promise => { + return new Promise((resolve) => { + const cliBin = join(__dirname, '../index.js'); + const cliArgs = [cliBin, 'add', componentName, '--yes']; + if (registryUrl) cliArgs.push('--registry', registryUrl); + + const chunks: string[] = []; + const child = spawn(process.execPath, cliArgs, { cwd, timeout: 60_000 }); + + child.stdout.on('data', (d: Buffer) => chunks.push(d.toString())); + child.stderr.on('data', (d: Buffer) => chunks.push(d.toString())); + + child.on('error', (err) => { + resolve({ ok: false, output: chunks.join('').trim(), errorMessage: err.message }); + }); + + child.on('close', (code) => { + const output = chunks.join('').trim(); + resolve(code !== 0 ? { ok: false, output, exitCode: code } : { ok: true, output }); + }); + }); + }); + + let cachedRegistry: Registry | null = null; + const getRegistry = async (): Promise => { + if (!cachedRegistry) cachedRegistry = await fetchRegistry(registryUrl); + return cachedRegistry; + }; + + const server = new Server( + { name: 'sanring', version: getCliVersion() }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'list_components', + description: 'List all available Sanring UI components with their names and descriptions.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'search_components', + description: + 'Search Sanring UI components by name or description. Returns matching components ranked by relevance (name matches first).', + inputSchema: { + type: 'object' as const, + properties: { + query: { type: 'string', description: 'Search term' }, + }, + required: ['query'], + }, + }, + { + name: 'get_component_info', + description: + 'Get detailed information about a specific Sanring UI component: files, component dependencies that will be auto-installed, and required peer dependencies.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: "Component name (e.g. 'button', 'dialog', 'accordion')", + }, + }, + required: ['name'], + }, + }, + { + name: 'plan_component_install', + description: + 'Preview what would happen if you added a Sanring UI component: which files would be written, which component dependencies would be auto-installed, and which peer packages would need to be installed. Does not modify the project.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: "Component name to preview (e.g. 'button', 'dialog')", + }, + }, + required: ['name'], + }, + }, + { + name: 'add_component', + description: + 'Add a Sanring UI component to an Angular project. Copies component source files into the project, installs peer dependencies, and handles component dependencies automatically. Call plan_component_install first to preview what will change.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Component name to install' }, + cwd: { + type: 'string', + description: + 'Absolute path to the Angular project root (the directory containing angular.json)', + }, + }, + required: ['name', 'cwd'], + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + switch (name) { + case 'list_components': { + const registry = await getRegistry(); + const lines = registry.components + .map((c) => ` ${c.name.padEnd(24)} ${c.description}`) + .join('\n'); + return { + content: [ + { + type: 'text' as const, + text: `Sanring UI — ${registry.components.length} components available:\n\n${lines}`, + }, + ], + }; + } + + case 'search_components': { + const validated = requireStrings(args, ['query']); + if ('isError' in validated) return validated; + const { query } = validated.values; + const registry = await getRegistry(); + const q = query.toLowerCase(); + const nameMatches = registry.components.filter((c) => c.name.toLowerCase().includes(q)); + const descMatches = registry.components.filter( + (c) => + !c.name.toLowerCase().includes(q) && c.description.toLowerCase().includes(q), + ); + const matches = [...nameMatches, ...descMatches]; + + if (matches.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: `No components matching "${query}". Run list_components to see all available components.`, + }, + ], + }; + } + + const lines = matches + .map((c) => ` ${c.name.padEnd(24)} ${c.description}`) + .join('\n'); + return { + content: [ + { + type: 'text' as const, + text: `Found ${matches.length} component(s) matching "${query}":\n\n${lines}`, + }, + ], + }; + } + + case 'get_component_info': { + const validated = requireStrings(args, ['name']); + if ('isError' in validated) return validated; + const { name: componentName } = validated.values; + const registry = await getRegistry(); + const component = registry.components.find((c) => c.name === componentName); + + if (!component) { + const available = registry.components.map((c) => c.name).join(', '); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Component "${componentName}" not found.\n\nAvailable: ${available}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: formatComponentDetail(component, registry), + }, + ], + }; + } + + case 'plan_component_install': { + const validated = requireStrings(args, ['name']); + if ('isError' in validated) return validated; + const { name: componentName } = validated.values; + const registry = await getRegistry(); + const component = registry.components.find((c) => c.name === componentName); + + if (!component) { + const available = registry.components.map((c) => c.name).join(', '); + return { + isError: true, + content: [{ type: 'text' as const, text: `Component "${componentName}" not found.\n\nAvailable: ${available}` }], + }; + } + + const registryIndex = createRegistryIndex(registry); + const { toInstall, autoAdded } = resolveInstallSet([componentName], registryIndex); + const peerDeps = collectPeerDeps(toInstall, registryIndex); + + const allFiles = toInstall.flatMap((c) => c.files); + const lines: string[] = [ + `Plan for: sanring add ${componentName}`, + '', + `Files to write (${allFiles.length}):`, + ...allFiles.map((f) => ` ${f}`), + ]; + if (autoAdded.length > 0) { + lines.push('', `Component dependencies (auto-installed): ${autoAdded.join(', ')}`); + } + if (Object.keys(peerDeps).length > 0) { + lines.push('', 'Peer packages to install:'); + for (const [pkg, ver] of Object.entries(peerDeps)) { + lines.push(` ${pkg}@${ver}`); + } + } + lines.push('', 'Run add_component to apply.'); + + return { content: [{ type: 'text' as const, text: lines.join('\n') }] }; + } + + case 'add_component': { + const validated = requireStrings(args, ['name', 'cwd']); + if ('isError' in validated) return validated; + const { name: componentName, cwd } = validated.values; + + if (!existsSync(join(cwd, 'angular.json'))) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `"${cwd}" does not appear to be an Angular project root (angular.json not found).`, + }, + ], + }; + } + + const result = await addComponent({ name: componentName, cwd }); + + if (result.errorMessage) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Failed to run sanring add: ${result.errorMessage}`, + }, + ], + }; + } + + if (!result.ok) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: + `sanring add "${componentName}" failed ` + + `(exit ${result.exitCode ?? 'unknown'}):\n\n${result.output}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: `Successfully added "${componentName}" to the project.\n\n${result.output}`, + }, + ], + }; + } + + default: + return { + isError: true, + content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], + }; + } + }); + + return server; +} + +async function startMcpServer(registryUrl?: string): Promise { + const server = createMcpServer({ registryUrl }); + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +export const mcpCommand = new Command('mcp') + .description( + 'Start the MCP server for AI agent integration (Claude Code, Cursor, Windsurf)', + ) + .option('--registry ', 'custom registry URL or local path') + .action(async (options: { registry?: string }) => { + await startMcpServer(options.registry); + }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7b980813..fe2de93d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import { doctorCommand } from './commands/doctor.js'; import { infoCommand } from './commands/info.js'; import { initCommand } from './commands/init.js'; import { listCommand } from './commands/list.js'; +import { mcpCommand } from './commands/mcp.js'; import { removeCommand } from './commands/remove.js'; import { searchCommand } from './commands/search.js'; import { migrateCommand } from './commands/migrate.js'; @@ -47,6 +48,7 @@ program.addCommand(removeCommand); program.addCommand(diffCommand); program.addCommand(updateCommand); program.addCommand(doctorCommand); +program.addCommand(mcpCommand); program.addCommand(migrateCommand); program.parse(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 412c1c65..fecb6637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: packages/cli: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) commander: specifier: ^12.0.0 version: 12.1.0 @@ -1407,6 +1410,16 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -5467,6 +5480,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -8462,6 +8497,10 @@ snapshots: dependencies: zod: 4.4.2 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@4.4.2: {} zod@4.4.3: {} diff --git a/todolist.md b/todolist.md index 5747d32f..4b7a4485 100644 --- a/todolist.md +++ b/todolist.md @@ -111,11 +111,21 @@ ## P8 — 沒有 MCP server 整合 -- [ ] 實作 `@sanring/cli` MCP server 支援,讓 Claude Code / Cursor 等 AI agent 能直接查詢、安裝元件 - -> **Branch**: 程式碼草稿已完成,暫存於 `feat/mcp-server` branch,待主要功能穩定後合回 main。 -> 已設計四個 tool:`list_components`、`search_components`、`get_component_info`、`add_component`。 -> 採 lower-level `Server` API(NodeNext ESM 相容、不額外依賴 zod),stdio transport。 +- [x] 實作 `@sanring/cli` MCP server 支援,讓 Claude Code / Cursor 等 AI agent 能直接查詢、安裝元件 + +**已完成**:新增 `packages/cli/src/commands/mcp.ts`,加入 `@modelcontextprotocol/sdk@1.30.0` 依賴,以 lower-level `Server` API(NodeNext ESM 相容、不額外依賴 zod)實作五個 tool:`list_components`(列出全部元件)、`search_components`(名稱優先搜尋)、`get_component_info`(含 files、自動安裝的 componentDeps、shared utilities、peerDeps)、`plan_component_install`(dry-run 預覽會寫入哪些檔案/componentDeps/peerDeps,不動專案)、`add_component`(`cwd` 參數指定 Angular project root,子程序執行 `sanring add --yes`)。所有 tool handler 都有 runtime input validation(`requireStrings`),找不到元件時統一回傳 `isError: true`。`sanring mcp` command 透過 stdio transport 啟動,`serverInfo.version` 正確讀取 CLI 版本。已補 `packages/cli/src/commands/mcp.test.ts`(`Client` + `InMemoryTransport`,覆蓋 tools/list、search、detail、plan、not-found isError、add tool boundary)與 `packages/cli/src/commands/mcp.e2e.test.ts`(`StdioClientTransport` 真實 spawn 編譯後的 CLI,驗證 cliBin 路徑解析與 `--registry` 傳遞);README 也補上 Claude Code / local development 設定方式與五個 tool 的說明表格。 + +**使用方式**(待合回 main 後生效):在 `.claude/mcp.json` 或 Claude Code 設定中加入: +```json +{ + "mcpServers": { + "sanring": { + "command": "npx", + "args": ["@sanring/cli@latest", "mcp"] + } + } +} +``` **對比**:shadcn 這一兩年加了 MCP 整合,AI coding agent 可以透過 MCP protocol 直接跟 registry 互動,不用手動下 shell 指令。跟目前透過 Claude Code 使用這個專案的情境直接相關。