Skip to content

Commit c8bb589

Browse files
authored
Merge pull request liam-hq#1416 from liam-hq/mcp-server
✨(dev): Add MCP server for UI component discovery
2 parents 1bbba55 + 98c1d04 commit c8bb589

File tree

8 files changed

+640
-0
lines changed

8 files changed

+640
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ node_modules
44
.vercel
55
.env*.local
66
prism.wasm
7+
.cursor/mcp.json
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# @liam-hq/mcp-server
2+
3+
Model Context Protocol (MCP) server for Liam development tools integration for AI agents.
4+
5+
## Overview
6+
7+
This package provides MCP server functionality that helps Cursor IDE interact with Liam's UI components, offering the following tools:
8+
9+
- `list_components`: Lists all UI components in the Liam UI package
10+
- `get_component_files`: Gets the contents of a specific UI component's files
11+
12+
## Setup
13+
14+
### Installation
15+
16+
The package is part of the Liam monorepo. Install all dependencies with:
17+
18+
```bash
19+
pnpm install
20+
```
21+
22+
## Cursor IDE Integration
23+
24+
To use this MCP server with Cursor IDE, you need to configure a `.cursor/mcp.json` file in the root of your project.
25+
26+
### Setting up mcp.json
27+
28+
1. Create a `.cursor` directory in the project root if it doesn't exist
29+
2. Create an `mcp.json` file inside this directory with the following structure:
30+
31+
```json
32+
{
33+
"mcpServers": {
34+
"liam-development-mcp-server": {
35+
"command": "/path/to/your/node",
36+
"args": [
37+
"--experimental-strip-types",
38+
"/path/to/your/liam/frontend/internal-packages/mcp-server/src/index.ts"
39+
]
40+
}
41+
}
42+
}
43+
```
44+
45+
Replace `/path/to/your/node` with the path to your Node.js executable and adjust the path to the index.ts file according to your local environment.
46+
47+
### Finding Your Node Path
48+
49+
You can find your Node.js path by running:
50+
51+
```bash
52+
which node
53+
```
54+
55+
### Why This File Isn't Committed to Git
56+
57+
The `mcp.json` file is not committed to Git because it contains absolute local file paths that are specific to each developer's environment. This prevents sharing a standard configuration that would work across all developer machines.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": ["../../packages/configs/biome.jsonc"]
3+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@liam-hq/mcp-server",
3+
"license": "Apache-2.0",
4+
"private": true,
5+
"version": "0.1.0",
6+
"type": "module",
7+
"main": "src/index.ts",
8+
"dependencies": {
9+
"@modelcontextprotocol/sdk": "1.10.1",
10+
"zod": "3.24.3"
11+
},
12+
"devDependencies": {
13+
"@biomejs/biome": "1.9.4",
14+
"@liam-hq/configs": "workspace:*",
15+
"@types/node": "22.14.1",
16+
"concurrently": "9.1.2",
17+
"tsx": "4.19.3",
18+
"typescript": "5.8.3"
19+
},
20+
"scripts": {
21+
"dev": "tsx watch src/index.ts",
22+
"fmt": "concurrently \"pnpm:fmt:*\"",
23+
"fmt:biome": "biome check --write --unsafe .",
24+
"lint": "concurrently \"pnpm:lint:*\"",
25+
"lint:biome": "biome check .",
26+
"lint:tsc": "tsc --noEmit"
27+
}
28+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env node
2+
import * as fs from 'node:fs/promises'
3+
import * as path from 'node:path'
4+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
6+
import { z } from 'zod'
7+
8+
import { dirname } from 'node:path'
9+
import { fileURLToPath } from 'node:url'
10+
11+
const __filename = fileURLToPath(import.meta.url)
12+
const __dirname = dirname(__filename)
13+
const uiComponentsPath = path.resolve(
14+
__dirname,
15+
'../../../packages/ui/src/components',
16+
)
17+
18+
const server = new McpServer({
19+
name: 'liam-development-mcp-server',
20+
version: '0.1.0',
21+
})
22+
23+
type McpToolSuccessResult = {
24+
content: { type: 'text'; text: string }[]
25+
isError?: false
26+
}
27+
28+
type McpToolErrorResult = {
29+
content: { type: 'text'; text: string }[]
30+
isError: true
31+
error: string
32+
}
33+
34+
type McpToolResult = McpToolSuccessResult | McpToolErrorResult
35+
36+
server.tool(
37+
'list_components',
38+
'Lists all component directories in the UI package',
39+
async (): Promise<McpToolResult> => {
40+
try {
41+
const entries = await fs.readdir(uiComponentsPath, {
42+
withFileTypes: true,
43+
})
44+
const componentDirs = entries
45+
.filter((entry) => entry.isDirectory())
46+
.map((dir) => dir.name)
47+
48+
return { content: [{ type: 'text', text: componentDirs.join('\n') }] }
49+
} catch (error) {
50+
const errorMessage =
51+
error instanceof Error ? error.message : String(error)
52+
return {
53+
content: [],
54+
isError: true,
55+
error: `Failed to list components: ${errorMessage}`,
56+
}
57+
}
58+
},
59+
)
60+
61+
server.tool(
62+
'get_component_files',
63+
'Gets the content of all .tsx files within a specified UI component directory',
64+
{ componentName: z.string() },
65+
async ({ componentName }): Promise<McpToolResult> => {
66+
const componentDir = path.join(uiComponentsPath, componentName)
67+
68+
// Security check: ensure the resolved path is within the components directory
69+
const resolvedPath = path.resolve(componentDir)
70+
if (!resolvedPath.startsWith(uiComponentsPath)) {
71+
return {
72+
content: [],
73+
isError: true,
74+
error: 'Security error: Path traversal attempt detected.',
75+
}
76+
}
77+
78+
try {
79+
// Check if component directory exists
80+
const stats = await fs.stat(componentDir)
81+
if (!stats.isDirectory()) {
82+
return {
83+
content: [],
84+
isError: true,
85+
error: `Component "${componentName}" is not a directory.`,
86+
}
87+
}
88+
89+
// Get all .tsx files in the component directory
90+
const files = await fs.readdir(componentDir)
91+
const tsxFiles = files.filter((file) => file.endsWith('.tsx'))
92+
93+
if (tsxFiles.length === 0) {
94+
return {
95+
content: [],
96+
isError: true,
97+
error: `No .tsx files found in component "${componentName}".`,
98+
}
99+
}
100+
101+
// Read content of each .tsx file
102+
const fileContents = await Promise.all(
103+
tsxFiles.map(async (file) => {
104+
const content = await fs.readFile(
105+
path.join(componentDir, file),
106+
'utf8',
107+
)
108+
return `=== ${file} ===\n${content}`
109+
}),
110+
)
111+
112+
return { content: [{ type: 'text', text: fileContents.join('\n\n') }] }
113+
} catch (error) {
114+
// Check if it's the specific 'ENOENT' error
115+
if (
116+
error instanceof Error &&
117+
'code' in error &&
118+
error.code === 'ENOENT'
119+
) {
120+
return {
121+
content: [],
122+
isError: true,
123+
error: `Component "${componentName}" not found.`,
124+
}
125+
}
126+
// Handle other errors
127+
const errorMessage =
128+
error instanceof Error ? error.message : String(error)
129+
return {
130+
content: [],
131+
isError: true,
132+
error: `Failed to get component files: ${errorMessage}`,
133+
}
134+
}
135+
},
136+
)
137+
138+
// Connect server to stdio transport
139+
async function main() {
140+
try {
141+
console.info('Starting liam-development-mcp-server...')
142+
await server.connect(new StdioServerTransport())
143+
console.info('Server connected')
144+
} catch (error) {
145+
console.error('Server error:', error)
146+
process.exit(1)
147+
}
148+
}
149+
150+
main()
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "@liam-hq/configs/tsconfig/base.json",
3+
"compilerOptions": {
4+
"baseUrl": ".",
5+
"module": "NodeNext",
6+
"moduleResolution": "NodeNext"
7+
},
8+
"include": ["src/**/*"]
9+
}

0 commit comments

Comments
 (0)