Skip to content

Commit 2da180d

Browse files
committed
feat: add @topology/mcp package with MCP Server, Resources and Tools
1 parent 7a9adda commit 2da180d

File tree

8 files changed

+1060
-2
lines changed

8 files changed

+1060
-2
lines changed

CLAUDE.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ code-topology/
147147
│ │ ├── reporter/ # 報告生成 (Markdown/JSON)
148148
│ │ ├── plugins/ # 語言插件系統 + built-in plugins
149149
│ │ └── analyze.ts # 高階分析 API (AST + Semantic 雙引擎)
150+
│ ├── mcp/ # @topology/mcp - MCP Server (Model Context Protocol for AI agents)
151+
│ │ └── src/
152+
│ │ ├── index.ts # bin entry: McpServer + stdio transport
153+
│ │ ├── state.ts # TopologyState: 快取分析結果
154+
│ │ ├── tools.ts # 6 個 MCP tools (analyze, dependencies, impact, etc.)
155+
│ │ └── resources.ts # 3 個 MCP resources (graph, stats, node)
150156
│ ├── server/ # @topology/server - L3: WebSocket 伺服器, 檔案監視
151157
│ └── web/ # @topology/web - L4: Next.js + React Flow + elkjs 視覺化
152158
├── cli/ # @topology/cli - 薄殼 CLI (commander → core + server)
@@ -157,7 +163,7 @@ code-topology/
157163
└── CLAUDE.md
158164
```
159165

160-
**Build DAG**: `protocol``core``server``cli` / `web`
166+
**Build DAG**: `protocol``core``server` / `mcp` `cli` / `web`
161167

162168
**關鍵技術**:
163169
- **佈局引擎**: elkjs (取代 dagre)
@@ -166,6 +172,7 @@ code-topology/
166172
- **插件系統**: LanguagePlugin interface + pluginRegistry
167173
- **向量引擎**: onnxruntime-node + Xenova/all-MiniLM-L6-v2 (384 維, int8 量化)
168174
- **本地緩存**: better-sqlite3 (ParseCache + EmbeddingCache)
175+
- **MCP Server**: @modelcontextprotocol/sdk (stdio transport, 6 tools + 3 resources)
169176

170177
---
171178

@@ -199,7 +206,12 @@ code-topology/
199206

200207
### Phase 3: The "Arbiter" (Agent Interaction)
201208

202-
* [ ] 實作 MCP Server:讓 Cursor/Claude 可以讀取拓撲圖數據。
209+
* [x] **MCP Server** (`@topology/mcp`): 實作 Model Context Protocol 伺服器,讓 Cursor/Claude Desktop 直接查詢拓撲圖。
210+
* Stdio transport(零網路開銷)
211+
* 3 Resources: `topology://graph``topology://stats``topology://node/{filePath}`
212+
* 6 Tools: `analyze``get_dependencies``get_broken_edges``find_similar_files``get_file_impact``generate_report`
213+
* TopologyState 快取管理(首次呼叫觸發分析,後續讀快取)
214+
* BFS 依賴鏈搜尋(正向/反向,可設定深度)
203215
* [ ] 實作「衝突預警」:當兩個 Git 分支修改了語義相近的節點時,UI 發出警報。
204216
* [ ] 推出 Docker Image,支援團隊私有化部署。
205217

packages/mcp/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@topology/mcp",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "MCP server for code-topology — expose topology data to AI agents",
7+
"main": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"bin": {
10+
"topology-mcp": "./dist/index.js"
11+
},
12+
"scripts": {
13+
"build": "tsc",
14+
"dev": "tsc --watch"
15+
},
16+
"dependencies": {
17+
"@topology/protocol": "workspace:*",
18+
"@topology/core": "workspace:*",
19+
"@modelcontextprotocol/sdk": "^1.12.0",
20+
"zod": "^3.24.0"
21+
},
22+
"devDependencies": {
23+
"@types/node": "^22.10.0",
24+
"typescript": "^5.7.0"
25+
}
26+
}

packages/mcp/src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/usr/bin/env node
2+
3+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5+
import { TopologyState } from './state.js';
6+
import { registerResources } from './resources.js';
7+
import { registerTools } from './tools.js';
8+
9+
const server = new McpServer({
10+
name: 'code-topology',
11+
version: '0.1.0',
12+
});
13+
14+
const state = new TopologyState();
15+
16+
registerResources(server, state);
17+
registerTools(server, state);
18+
19+
const transport = new StdioServerTransport();
20+
await server.connect(transport);

packages/mcp/src/resources.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import type { TopologyState } from './state.js';
3+
4+
export function registerResources(server: McpServer, state: TopologyState): void {
5+
// Resource 1: Full topology graph
6+
server.resource(
7+
'graph',
8+
'topology://graph',
9+
{ description: 'Full topology graph with all nodes and edges (dependency + semantic)' },
10+
async () => {
11+
const graph = await state.ensureGraph();
12+
return {
13+
contents: [
14+
{
15+
uri: 'topology://graph',
16+
mimeType: 'application/json',
17+
text: JSON.stringify(graph, null, 2),
18+
},
19+
],
20+
};
21+
},
22+
);
23+
24+
// Resource 2: Summary statistics
25+
server.resource(
26+
'stats',
27+
'topology://stats',
28+
{ description: 'Summary statistics: node count, edge count, broken edges, semantic edges, languages' },
29+
async () => {
30+
const graph = await state.ensureGraph();
31+
32+
const languageCounts: Record<string, number> = {};
33+
for (const node of graph.nodes) {
34+
const lang = node.language ?? 'unknown';
35+
languageCounts[lang] = (languageCounts[lang] ?? 0) + 1;
36+
}
37+
38+
const stats = {
39+
nodeCount: graph.nodes.length,
40+
edgeCount: graph.edges.length,
41+
dependencyEdges: graph.edges.filter((e) => e.linkType !== 'semantic').length,
42+
semanticEdges: graph.edges.filter((e) => e.linkType === 'semantic').length,
43+
brokenEdges: graph.edges.filter((e) => e.isBroken).length,
44+
languages: languageCounts,
45+
timestamp: graph.timestamp,
46+
};
47+
48+
return {
49+
contents: [
50+
{
51+
uri: 'topology://stats',
52+
mimeType: 'application/json',
53+
text: JSON.stringify(stats, null, 2),
54+
},
55+
],
56+
};
57+
},
58+
);
59+
60+
// Resource 3: Node detail by file path (ResourceTemplate)
61+
server.resource(
62+
'node',
63+
new ResourceTemplate('topology://node/{filePath}', { list: undefined }),
64+
{ description: 'Node details and related edges for a specific file (use URL-encoded path)' },
65+
async (uri, params) => {
66+
const graph = await state.ensureGraph();
67+
const filePath = decodeURIComponent(params.filePath as string);
68+
69+
const node = graph.nodes.find((n) => n.id === filePath);
70+
if (!node) {
71+
return {
72+
contents: [
73+
{
74+
uri: uri.href,
75+
mimeType: 'application/json',
76+
text: JSON.stringify({ error: `Node not found: ${filePath}` }),
77+
},
78+
],
79+
};
80+
}
81+
82+
const imports = graph.edges.filter(
83+
(e) => e.source === filePath && e.linkType !== 'semantic',
84+
);
85+
const importedBy = graph.edges.filter(
86+
(e) => e.target === filePath && e.linkType !== 'semantic',
87+
);
88+
const similar = graph.edges.filter(
89+
(e) =>
90+
e.linkType === 'semantic' &&
91+
(e.source === filePath || e.target === filePath),
92+
);
93+
94+
const detail = {
95+
node,
96+
imports: imports.map((e) => ({ target: e.target, isBroken: e.isBroken })),
97+
importedBy: importedBy.map((e) => ({ source: e.source, isBroken: e.isBroken })),
98+
similar: similar.map((e) => ({
99+
file: e.source === filePath ? e.target : e.source,
100+
similarity: e.similarity,
101+
})),
102+
};
103+
104+
return {
105+
contents: [
106+
{
107+
uri: uri.href,
108+
mimeType: 'application/json',
109+
text: JSON.stringify(detail, null, 2),
110+
},
111+
],
112+
};
113+
},
114+
);
115+
}

packages/mcp/src/state.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { analyzeDirectory, type AnalyzeOptions } from '@topology/core';
2+
import type { TopologyGraph } from '@topology/protocol';
3+
4+
export class TopologyState {
5+
private graph: TopologyGraph | null = null;
6+
private analyzeInProgress: Promise<TopologyGraph> | null = null;
7+
private readonly analyzePath: string;
8+
9+
constructor(path?: string) {
10+
this.analyzePath = path ?? process.cwd();
11+
}
12+
13+
async ensureGraph(options?: AnalyzeOptions): Promise<TopologyGraph> {
14+
if (this.graph) {
15+
return this.graph;
16+
}
17+
return this.refresh(options);
18+
}
19+
20+
async refresh(options?: AnalyzeOptions): Promise<TopologyGraph> {
21+
// Deduplicate concurrent calls
22+
if (this.analyzeInProgress) {
23+
return this.analyzeInProgress;
24+
}
25+
26+
this.analyzeInProgress = analyzeDirectory(this.analyzePath, options)
27+
.then((graph) => {
28+
this.graph = graph;
29+
this.analyzeInProgress = null;
30+
return graph;
31+
})
32+
.catch((err) => {
33+
this.analyzeInProgress = null;
34+
throw err;
35+
});
36+
37+
return this.analyzeInProgress;
38+
}
39+
40+
getGraph(): TopologyGraph | null {
41+
return this.graph;
42+
}
43+
}

0 commit comments

Comments
 (0)