From b8f9e5ed9a28bb10e9bef54537d4924a32d2257e Mon Sep 17 00:00:00 2001 From: charlieTai Date: Thu, 6 Aug 2026 16:09:55 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(cli):=20P8=20=E2=80=94=20add=20MCP=20s?= =?UTF-8?q?erver=20for=20AI=20agent=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement stdio MCP server via @modelcontextprotocol/sdk (lower-level Server API, NodeNext ESM compatible, no zod dependency). Exposes four tools: list_components, search_components, get_component_info, add_component. add_component spawns `sanring add --yes` as a subprocess in the target project. Usage: add `sanring mcp` to .claude/mcp.json as a stdio MCP server. Branch rationale: keeping separate from main until core UI functionality is stable. Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/package.json | 1 + packages/cli/src/commands/mcp.ts | 273 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + pnpm-lock.yaml | 39 +++++ todolist.md | 20 ++- 5 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/commands/mcp.ts 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/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..8e1269b9 --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,273 @@ +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 { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Command } from 'commander'; +import { createRegistryIndex, fetchRegistry, 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, + allComponents: RegistryComponent[], +): string { + const registryIndex = createRegistryIndex({ + name: '', + shared: [], + components: allComponents, + }); + 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'); +} + +async function startMcpServer(registryUrl?: string): Promise { + 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: '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.', + 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 fetchRegistry(registryUrl); + 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 { query } = args as { query: string }; + const registry = await fetchRegistry(registryUrl); + 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 { name: componentName } = args as { name: string }; + const registry = await fetchRegistry(registryUrl); + const component = registry.components.find((c) => c.name === componentName); + + if (!component) { + const available = registry.components.map((c) => c.name).join(', '); + return { + content: [ + { + type: 'text' as const, + text: `Component "${componentName}" not found.\n\nAvailable: ${available}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: formatComponentDetail(component, registry.components), + }, + ], + }; + } + + case 'add_component': { + const { name: componentName, cwd } = args as { name: string; cwd: string }; + const cliBin = join(__dirname, 'index.js'); + + const result = spawnSync(process.execPath, [cliBin, 'add', componentName, '--yes'], { + cwd, + encoding: 'utf-8', + timeout: 60_000, + }); + + if (result.error) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `Failed to run sanring add: ${result.error.message}`, + }, + ], + }; + } + + const output = [result.stdout, result.stderr] + .filter(Boolean) + .join('\n') + .trim(); + + if (result.status !== 0) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `sanring add "${componentName}" failed (exit ${result.status ?? 'unknown'}):\n\n${output}`, + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: `Successfully added "${componentName}" to the project.\n\n${output}`, + }, + ], + }; + } + + default: + return { + isError: true, + content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], + }; + } + }); + + 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 fbd21f4f..fcd161b3 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 { updateCommand } from './commands/update.js'; @@ -46,5 +47,6 @@ program.addCommand(removeCommand); program.addCommand(diffCommand); program.addCommand(updateCommand); program.addCommand(doctorCommand); +program.addCommand(mcpCommand); 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 11eb53ae..249c9777 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 能直接查詢、安裝元件 + +**已完成(暫存於 `feat/mcp-server` branch,待主要功能穩定後合回 main)**:新增 `packages/cli/src/commands/mcp.ts`,加入 `@modelcontextprotocol/sdk@1.30.0` 依賴,以 lower-level `Server` API(NodeNext ESM 相容、不額外依賴 zod)實作四個 tool:`list_components`(列出全部 52 個元件)、`search_components`(名稱優先 fuzzy 搜尋)、`get_component_info`(含 files、自動安裝的 componentDeps、peerDeps)、`add_component`(`cwd` 參數指定 Angular project root,子程序執行 `sanring add --yes`)。`sanring mcp` command 透過 stdio transport 啟動,`serverInfo.version` 正確讀取 CLI 版本。手動驗證 initialize + tools/list + list_components + search_components 全部正常回傳。 + +**使用方式**(待合回 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 使用這個專案的情境直接相關。 From 181afc6e8328a6ba0336bfe57c27bc1c7f68b515 Mon Sep 17 00:00:00 2001 From: charlieTai Date: Fri, 7 Aug 2026 17:09:13 +0800 Subject: [PATCH 2/7] =?UTF-8?q?perf(mcp):=20registry=20cache=20+=20async?= =?UTF-8?q?=20spawn=20+=20cwd=20=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchRegistry 結果 cache 在 server instance 內,同 session 只 fetch 一次 - spawnSync → spawn(async),不再阻塞 event loop - add_component 先驗證 angular.json 存在,cwd 錯誤提早回報清楚訊息 Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/mcp.ts | 126 +++++++++++++++++++++++-------- 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 8e1269b9..8cbf3dc0 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -4,12 +4,17 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; -import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +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 RegistryComponent } from '../registry.js'; +import { + createRegistryIndex, + fetchRegistry, + type Registry, + type RegistryComponent, +} from '../registry.js'; import { resolveInstallSet, collectPeerDeps } from './add.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -28,13 +33,9 @@ function getCliVersion(): string { function formatComponentDetail( component: RegistryComponent, - allComponents: RegistryComponent[], + registry: Registry, ): string { - const registryIndex = createRegistryIndex({ - name: '', - shared: [], - components: allComponents, - }); + const registryIndex = createRegistryIndex(registry); const { toInstall, autoAdded } = resolveInstallSet([component.name], registryIndex); const peerDeps = collectPeerDeps(toInstall, registryIndex); @@ -61,7 +62,63 @@ function formatComponentDetail( return lines.join('\n'); } -async function startMcpServer(registryUrl?: string): Promise { +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; +} + +function runAddComponentWithCli({ name: componentName, cwd }: AddComponentToolInput): Promise { + return new Promise((resolve) => { + const cliBin = join(__dirname, 'index.js'); + const chunks: string[] = []; + + const child = spawn(process.execPath, [cliBin, 'add', componentName, '--yes'], { + 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(); + if (code !== 0) { + resolve({ ok: false, output, exitCode: code }); + } else { + resolve({ ok: true, output }); + } + }); + }); +} + +export function createMcpServer(options: CreateMcpServerOptions = {}): Server { + const registryUrl = options.registryUrl; + const addComponent = options.addComponent ?? runAddComponentWithCli; + + 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: {} } }, @@ -126,7 +183,7 @@ async function startMcpServer(registryUrl?: string): Promise { switch (name) { case 'list_components': { - const registry = await fetchRegistry(registryUrl); + const registry = await getRegistry(); const lines = registry.components .map((c) => ` ${c.name.padEnd(24)} ${c.description}`) .join('\n'); @@ -142,7 +199,7 @@ async function startMcpServer(registryUrl?: string): Promise { case 'search_components': { const { query } = args as { query: string }; - const registry = await fetchRegistry(registryUrl); + const registry = await getRegistry(); const q = query.toLowerCase(); const nameMatches = registry.components.filter((c) => c.name.toLowerCase().includes(q)); const descMatches = registry.components.filter( @@ -177,7 +234,7 @@ async function startMcpServer(registryUrl?: string): Promise { case 'get_component_info': { const { name: componentName } = args as { name: string }; - const registry = await fetchRegistry(registryUrl); + const registry = await getRegistry(); const component = registry.components.find((c) => c.name === componentName); if (!component) { @@ -196,7 +253,7 @@ async function startMcpServer(registryUrl?: string): Promise { content: [ { type: 'text' as const, - text: formatComponentDetail(component, registry.components), + text: formatComponentDetail(component, registry), }, ], }; @@ -204,38 +261,42 @@ async function startMcpServer(registryUrl?: string): Promise { case 'add_component': { const { name: componentName, cwd } = args as { name: string; cwd: string }; - const cliBin = join(__dirname, 'index.js'); - const result = spawnSync(process.execPath, [cliBin, 'add', componentName, '--yes'], { - cwd, - encoding: 'utf-8', - timeout: 60_000, - }); - - if (result.error) { + if (!existsSync(join(cwd, 'angular.json'))) { return { isError: true, content: [ { type: 'text' as const, - text: `Failed to run sanring add: ${result.error.message}`, + text: `"${cwd}" does not appear to be an Angular project root (angular.json not found).`, }, ], }; } - const output = [result.stdout, result.stderr] - .filter(Boolean) - .join('\n') - .trim(); + 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.status !== 0) { + if (!result.ok) { return { isError: true, content: [ { type: 'text' as const, - text: `sanring add "${componentName}" failed (exit ${result.status ?? 'unknown'}):\n\n${output}`, + text: + `sanring add "${componentName}" failed ` + + `(exit ${result.exitCode ?? 'unknown'}):\n\n${result.output}`, }, ], }; @@ -245,7 +306,7 @@ async function startMcpServer(registryUrl?: string): Promise { content: [ { type: 'text' as const, - text: `Successfully added "${componentName}" to the project.\n\n${output}`, + text: `Successfully added "${componentName}" to the project.\n\n${result.output}`, }, ], }; @@ -259,6 +320,11 @@ async function startMcpServer(registryUrl?: string): Promise { } }); + return server; +} + +async function startMcpServer(registryUrl?: string): Promise { + const server = createMcpServer({ registryUrl }); const transport = new StdioServerTransport(); await server.connect(transport); } From 3b28cf8ea6f732a898348774a773c7ba1aa3ed18 Mon Sep 17 00:00:00 2001 From: charlieTai Date: Fri, 7 Aug 2026 18:16:17 +0800 Subject: [PATCH 3/7] feat(mcp): runtime input validation for all tool handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 requireStrings() helper:驗證 args 必填欄位型別與非空,失敗回傳 isError MCP content - search_components / get_component_info / add_component 三個 handler 換掉 unsafe cast - 補 validation 測試:missing field、空白字串均回傳 isError + 清楚訊息 Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/mcp.test.ts | 136 ++++++++++++++++++++++++++ packages/cli/src/commands/mcp.ts | 31 +++++- 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/commands/mcp.test.ts diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 00000000..c0022431 --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -0,0 +1,136 @@ +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', + '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('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 index 8cbf3dc0..c21e3822 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -62,6 +62,25 @@ function formatComponentDetail( 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; @@ -198,7 +217,9 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { } case 'search_components': { - const { query } = args as { query: string }; + 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)); @@ -233,7 +254,9 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { } case 'get_component_info': { - const { name: componentName } = args as { name: string }; + 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); @@ -260,7 +283,9 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { } case 'add_component': { - const { name: componentName, cwd } = args as { name: string; cwd: string }; + 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 { From e14e2c35f2231f0358558d951a432078de3e05de Mon Sep 17 00:00:00 2001 From: charlieTai Date: Fri, 7 Aug 2026 18:17:08 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=20plan=5Fco?= =?UTF-8?q?mponent=5Finstall=20tool=EF=BC=88dryRun=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 tool:preview 會寫入哪些 files、auto-install 哪些 component deps、需安裝哪些 peer packages - add_component description 補 hint 引導 agent 先呼叫 plan_component_install - 補測試:驗證 plan 回傳正確內容且不修改專案 Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/mcp.test.ts | 17 ++++++++ packages/cli/src/commands/mcp.ts | 57 ++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index c0022431..93790cb0 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -78,6 +78,7 @@ describe('mcp server', () => { 'list_components', 'search_components', 'get_component_info', + 'plan_component_install', 'add_component', ]); }); @@ -101,6 +102,22 @@ describe('mcp server', () => { 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 for missing or empty string arguments', async () => { const testClient = await connect(); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index c21e3822..f97bf609 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -177,10 +177,25 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { 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.', + '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: { @@ -282,6 +297,46 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { }; } + 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; From 378773cc3c0adbe7de745fadf6c8a9e16bdf4bba Mon Sep 17 00:00:00 2001 From: charlieTai Date: Fri, 7 Aug 2026 18:20:05 +0800 Subject: [PATCH 5/7] =?UTF-8?q?test(mcp):=20stdio=20e2e=20test=20+=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20cliBin=20=E8=B7=AF=E5=BE=91=E8=88=87=20--r?= =?UTF-8?q?egistry=20=E5=82=B3=E9=81=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 mcp.e2e.test.ts:build CLI → 以 stdio 啟動真實 MCP server → 呼叫 add_component → 驗證檔案寫入 - 修正 cliBin 路徑:dist/commands/index.js → dist/index.js(多了一層 ../) - 修正 add spawn:registryUrl 納入 closure,帶 --registry 給 add 指令(原本 e2e 會打 production registry) Co-Authored-By: Claude Sonnet 4.6 --- packages/cli/src/commands/mcp.e2e.test.ts | 84 +++++++++++++++++++++++ packages/cli/src/commands/mcp.ts | 46 ++++++------- 2 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 packages/cli/src/commands/mcp.e2e.test.ts 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.ts b/packages/cli/src/commands/mcp.ts index f97bf609..0f5deb73 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -100,37 +100,31 @@ export interface CreateMcpServerOptions { ) => AddComponentToolResult | Promise; } -function runAddComponentWithCli({ name: componentName, cwd }: AddComponentToolInput): Promise { - return new Promise((resolve) => { - const cliBin = join(__dirname, 'index.js'); - const chunks: string[] = []; - - const child = spawn(process.execPath, [cliBin, 'add', componentName, '--yes'], { - cwd, - timeout: 60_000, - }); +export function createMcpServer(options: CreateMcpServerOptions = {}): Server { + const registryUrl = options.registryUrl; - child.stdout.on('data', (d: Buffer) => chunks.push(d.toString())); - child.stderr.on('data', (d: Buffer) => chunks.push(d.toString())); + 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); - child.on('error', (err) => { - resolve({ ok: false, output: chunks.join('').trim(), errorMessage: err.message }); - }); + const chunks: string[] = []; + const child = spawn(process.execPath, cliArgs, { cwd, timeout: 60_000 }); - child.on('close', (code) => { - const output = chunks.join('').trim(); - if (code !== 0) { - resolve({ ok: false, output, exitCode: code }); - } else { - resolve({ ok: true, output }); - } + 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 }); + }); }); }); -} - -export function createMcpServer(options: CreateMcpServerOptions = {}): Server { - const registryUrl = options.registryUrl; - const addComponent = options.addComponent ?? runAddComponentWithCli; let cachedRegistry: Registry | null = null; const getRegistry = async (): Promise => { From 43180cb1609d2486fcc23a7a27e235c51fbeb707 Mon Sep 17 00:00:00 2001 From: charlieTai Date: Fri, 7 Aug 2026 18:26:19 +0800 Subject: [PATCH 6/7] =?UTF-8?q?docs(mcp):=20=E8=A3=9C=20README=20mcp=20?= =?UTF-8?q?=E7=AB=A0=E7=AF=80=20+=20vscode=20mcp.json=20+=20todolist=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README:mcp 章節工具數從 4 → 5,補 plan_component_install 說明 - .vscode/mcp.json:加入 sanring MCP server 本地開發設定 - registry-fixture:補 utilsPeerDependencies 支援(mcp.test.ts 已使用) - todolist:移除「暫存於 feat/mcp-server」標記,更新完成狀態 Co-Authored-By: Claude Sonnet 4.6 --- .vscode/mcp.json | 4 ++ packages/cli/README.md | 48 +++++++++++++++++++ .../cli/src/__tests__/registry-fixture.ts | 8 +++- todolist.md | 2 +- 4 files changed, 60 insertions(+), 2 deletions(-) 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/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/todolist.md b/todolist.md index 70a4f09b..06b02d8b 100644 --- a/todolist.md +++ b/todolist.md @@ -113,7 +113,7 @@ - [x] 實作 `@sanring/cli` MCP server 支援,讓 Claude Code / Cursor 等 AI agent 能直接查詢、安裝元件 -**已完成(暫存於 `feat/mcp-server` branch,待主要功能穩定後合回 main)**:新增 `packages/cli/src/commands/mcp.ts`,加入 `@modelcontextprotocol/sdk@1.30.0` 依賴,以 lower-level `Server` API(NodeNext ESM 相容、不額外依賴 zod)實作四個 tool:`list_components`(列出全部 52 個元件)、`search_components`(名稱優先 fuzzy 搜尋)、`get_component_info`(含 files、自動安裝的 componentDeps、peerDeps)、`add_component`(`cwd` 參數指定 Angular project root,子程序執行 `sanring add --yes`)。`sanring mcp` command 透過 stdio transport 啟動,`serverInfo.version` 正確讀取 CLI 版本。手動驗證 initialize + tools/list + list_components + search_components 全部正常回傳。 +**已完成**:新增 `packages/cli/src/commands/mcp.ts`,加入 `@modelcontextprotocol/sdk@1.30.0` 依賴,以 lower-level `Server` API(NodeNext ESM 相容、不額外依賴 zod)實作四個 tool:`list_components`(列出全部 52 個元件)、`search_components`(名稱優先搜尋)、`get_component_info`(含 files、自動安裝的 componentDeps、shared utilities、peerDeps)、`add_component`(`cwd` 參數指定 Angular project root,子程序執行 `sanring add --yes`)。`sanring mcp` command 透過 stdio transport 啟動,`serverInfo.version` 正確讀取 CLI 版本。已補 `packages/cli/src/commands/mcp.test.ts`,用 MCP SDK `Client` + `InMemoryTransport` 覆蓋 tools/list、search、detail、add tool boundary;README 也補上 Claude Code / local development 設定方式。 **使用方式**(待合回 main 後生效):在 `.claude/mcp.json` 或 Claude Code 設定中加入: ```json From 8c6af5c2f0f71baad239ac864315b1f5bcb62957 Mon Sep 17 00:00:00 2001 From: jack755051 Date: Sat, 8 Aug 2026 01:51:57 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(mcp):=20get=5Fcomponent=5Finfo=20not-fo?= =?UTF-8?q?und=20=E8=A3=9C=20isError=20+=20P8=20=E6=94=B6=E5=B0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_component_info 找不到元件時原本沒回傳 isError: true,跟 plan_component_install 同樣情境不一致,依賴 isError 判斷失敗的 AI agent 會誤判為成功呼叫。補上並加測試保護;todolist.md P8 內文同步更新成實際的 5 個 tool;新增 changeset。 Co-Authored-By: Claude Sonnet 5 --- .changeset/mcp-server-integration.md | 5 +++++ packages/cli/src/commands/mcp.test.ts | 18 ++++++++++++++++++ packages/cli/src/commands/mcp.ts | 1 + todolist.md | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-server-integration.md 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/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 93790cb0..1571bae0 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -118,6 +118,24 @@ describe('mcp server', () => { 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(); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 0f5deb73..ce885fc7 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -272,6 +272,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { if (!component) { const available = registry.components.map((c) => c.name).join(', '); return { + isError: true, content: [ { type: 'text' as const, diff --git a/todolist.md b/todolist.md index 06b02d8b..4b7a4485 100644 --- a/todolist.md +++ b/todolist.md @@ -113,7 +113,7 @@ - [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`(列出全部 52 個元件)、`search_components`(名稱優先搜尋)、`get_component_info`(含 files、自動安裝的 componentDeps、shared utilities、peerDeps)、`add_component`(`cwd` 參數指定 Angular project root,子程序執行 `sanring add --yes`)。`sanring mcp` command 透過 stdio transport 啟動,`serverInfo.version` 正確讀取 CLI 版本。已補 `packages/cli/src/commands/mcp.test.ts`,用 MCP SDK `Client` + `InMemoryTransport` 覆蓋 tools/list、search、detail、add tool boundary;README 也補上 Claude Code / local development 設定方式。 +**已完成**:新增 `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