Skip to content
5 changes: 5 additions & 0 deletions .changeset/mcp-server-integration.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 4 additions & 0 deletions .vscode/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
},
"sanring": {
"command": "node",
"args": ["${workspaceFolder}/packages/cli/dist/index.js", "mcp"]
}
}
}
48 changes: 48 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/__tests__/registry-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Registry } from '../registry.js';

export interface RegistryFixtureContent {
utils?: string;
utilsPeerDependencies?: Record<string, string>;
theme?: string;
widget?: string;
/** Second file added to the widget component — simulates a registry adding a new file post-install. */
Expand All @@ -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');
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/commands/mcp.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
171 changes: 171 additions & 0 deletions packages/cli/src/commands/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -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<Client> {
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');
});
});
Loading
Loading