@ppdocs/mcp 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +102 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +64 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +33 -0
- package/dist/storage/fileStorage.d.ts +41 -0
- package/dist/storage/fileStorage.js +458 -0
- package/dist/storage/httpClient.d.ts +65 -0
- package/dist/storage/httpClient.js +207 -0
- package/dist/storage/types.d.ts +73 -0
- package/dist/storage/types.js +2 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +149 -0
- package/package.json +31 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ppdocs MCP CLI
|
|
3
|
+
* 命令: init - 初始化项目配置
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
function parseArgs(args) {
|
|
8
|
+
const opts = { port: 20001, api: 'localhost' };
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (arg === '-p' || arg === '--project') {
|
|
12
|
+
opts.project = args[++i];
|
|
13
|
+
}
|
|
14
|
+
else if (arg === '-k' || arg === '--key') {
|
|
15
|
+
opts.key = args[++i];
|
|
16
|
+
}
|
|
17
|
+
else if (arg === '--port') {
|
|
18
|
+
opts.port = parseInt(args[++i], 10);
|
|
19
|
+
}
|
|
20
|
+
else if (arg === '--api') {
|
|
21
|
+
opts.api = args[++i];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!opts.project || !opts.key)
|
|
25
|
+
return null;
|
|
26
|
+
return opts;
|
|
27
|
+
}
|
|
28
|
+
function showHelp() {
|
|
29
|
+
console.log(`
|
|
30
|
+
ppdocs MCP CLI
|
|
31
|
+
|
|
32
|
+
Commands:
|
|
33
|
+
init Initialize ppdocs config in current directory
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
npx @ppdocs/mcp init -p <projectId> -k <key> [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-p, --project Project ID (required)
|
|
40
|
+
-k, --key API key (required)
|
|
41
|
+
--port API port (default: 20001)
|
|
42
|
+
--api API host (default: localhost)
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
npx @ppdocs/mcp init -p myproject -k abc123xyz
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
export function runCli(args) {
|
|
49
|
+
const cmd = args[0];
|
|
50
|
+
if (cmd === 'init') {
|
|
51
|
+
const opts = parseArgs(args.slice(1));
|
|
52
|
+
if (!opts) {
|
|
53
|
+
console.error('Error: -p (project) and -k (key) are required\n');
|
|
54
|
+
showHelp();
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
initProject(opts);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
61
|
+
showHelp();
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return false; // Not a CLI command, run as MCP server
|
|
65
|
+
}
|
|
66
|
+
function initProject(opts) {
|
|
67
|
+
const cwd = process.cwd();
|
|
68
|
+
// Create .ppdocs config
|
|
69
|
+
const ppdocsConfig = {
|
|
70
|
+
api: `http://${opts.api}:${opts.port}`,
|
|
71
|
+
projectId: opts.project,
|
|
72
|
+
key: opts.key,
|
|
73
|
+
};
|
|
74
|
+
const ppdocsPath = path.join(cwd, '.ppdocs');
|
|
75
|
+
fs.writeFileSync(ppdocsPath, JSON.stringify(ppdocsConfig, null, 2));
|
|
76
|
+
console.log(`✅ Created ${ppdocsPath}`);
|
|
77
|
+
// Create or update .mcp.json
|
|
78
|
+
const mcpPath = path.join(cwd, '.mcp.json');
|
|
79
|
+
let mcpConfig = {};
|
|
80
|
+
if (fs.existsSync(mcpPath)) {
|
|
81
|
+
try {
|
|
82
|
+
mcpConfig = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ignore parse error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
mcpConfig.mcpServers = {
|
|
89
|
+
...(mcpConfig.mcpServers || {}),
|
|
90
|
+
ppdocs: {
|
|
91
|
+
command: 'npx',
|
|
92
|
+
args: ['@ppdocs/mcp'],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
fs.writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
|
96
|
+
console.log(`✅ Created ${mcpPath}`);
|
|
97
|
+
console.log(`
|
|
98
|
+
🎉 Done! ppdocs MCP configured for project: ${opts.project}
|
|
99
|
+
|
|
100
|
+
Restart Claude Code to use ppdocs knowledge graph.
|
|
101
|
+
`);
|
|
102
|
+
}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ppdocs MCP Config
|
|
3
|
+
* 读取配置: 环境变量 > .ppdocs 文件
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
/**
|
|
8
|
+
* 从 .ppdocs 文件读取配置
|
|
9
|
+
*/
|
|
10
|
+
function readPpdocsFile() {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const configPath = path.join(cwd, '.ppdocs');
|
|
13
|
+
if (!fs.existsSync(configPath)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
18
|
+
const config = JSON.parse(content);
|
|
19
|
+
if (!config.api || !config.projectId || !config.key) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
apiUrl: `${config.api}/api/${config.projectId}/${config.key}`,
|
|
24
|
+
projectId: config.projectId,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 从环境变量读取配置
|
|
33
|
+
*/
|
|
34
|
+
function readEnvConfig() {
|
|
35
|
+
const apiUrl = process.env.PPDOCS_API_URL;
|
|
36
|
+
if (!apiUrl)
|
|
37
|
+
return null;
|
|
38
|
+
// URL 格式: http://localhost:20001/api/{projectId}/{password}
|
|
39
|
+
const match = apiUrl.match(/\/api\/([^/]+)\/[^/]+\/?$/);
|
|
40
|
+
const projectId = match?.[1] || 'unknown';
|
|
41
|
+
return { apiUrl, projectId };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* 加载配置 (优先级: 环境变量 > .ppdocs 文件)
|
|
45
|
+
*/
|
|
46
|
+
export function loadConfig() {
|
|
47
|
+
// 1. 尝试环境变量
|
|
48
|
+
const envConfig = readEnvConfig();
|
|
49
|
+
if (envConfig)
|
|
50
|
+
return envConfig;
|
|
51
|
+
// 2. 尝试 .ppdocs 文件
|
|
52
|
+
const fileConfig = readPpdocsFile();
|
|
53
|
+
if (fileConfig)
|
|
54
|
+
return fileConfig;
|
|
55
|
+
// 3. 报错
|
|
56
|
+
console.error('ERROR: ppdocs config not found');
|
|
57
|
+
console.error('');
|
|
58
|
+
console.error('Option 1: Run init command in your project directory:');
|
|
59
|
+
console.error(' npx @ppdocs/mcp init -p <projectId> -k <key>');
|
|
60
|
+
console.error('');
|
|
61
|
+
console.error('Option 2: Set environment variable:');
|
|
62
|
+
console.error(' PPDOCS_API_URL=http://localhost:20001/api/{projectId}/{key}');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ppdocs MCP - Knowledge Graph for Claude
|
|
4
|
+
*
|
|
5
|
+
* 使用方式:
|
|
6
|
+
* 1. CLI 初始化: npx @ppdocs/mcp init -p <projectId> -k <key>
|
|
7
|
+
* 2. MCP 服务: 自动读取 .ppdocs 配置或 PPDOCS_API_URL 环境变量
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
|
+
import { registerTools } from './tools/index.js';
|
|
12
|
+
import { initClient } from './storage/httpClient.js';
|
|
13
|
+
import { runCli } from './cli.js';
|
|
14
|
+
import { loadConfig } from './config.js';
|
|
15
|
+
// 检查是否为 CLI 命令
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
if (args.length > 0 && runCli(args)) {
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
// 运行 MCP 服务
|
|
21
|
+
async function main() {
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
initClient(config.apiUrl);
|
|
24
|
+
const server = new McpServer({ name: `ppdocs [${config.projectId}]`, version: '2.1.0' }, { capabilities: { tools: {} } });
|
|
25
|
+
registerTools(server, config.projectId);
|
|
26
|
+
const transport = new StdioServerTransport();
|
|
27
|
+
await server.connect(transport);
|
|
28
|
+
console.error(`ppdocs MCP v2.1 | project: ${config.projectId}`);
|
|
29
|
+
}
|
|
30
|
+
main().catch((err) => {
|
|
31
|
+
console.error('Fatal error:', err);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NodeData, Project, SearchResult, PathResult, BugfixRecord } from './types.js';
|
|
2
|
+
export declare function listProjects(): Promise<Project[]>;
|
|
3
|
+
export declare function ensureProject(projectId: string): Promise<Project>;
|
|
4
|
+
export declare function createProject(name: string): Promise<Project>;
|
|
5
|
+
export declare function getChangeMarkerTime(projectId: string): Promise<number>;
|
|
6
|
+
export declare function listNodes(projectId: string): Promise<NodeData[]>;
|
|
7
|
+
export declare function getNode(projectId: string, nodeId: string): Promise<NodeData | null>;
|
|
8
|
+
type CreateNodeInput = Omit<NodeData, 'id' | 'x' | 'y'> & {
|
|
9
|
+
x?: number;
|
|
10
|
+
y?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function createNode(projectId: string, node: CreateNodeInput): Promise<NodeData>;
|
|
13
|
+
export declare function updateNode(projectId: string, nodeId: string, updates: Partial<NodeData>): Promise<NodeData | null>;
|
|
14
|
+
export declare function deleteNode(projectId: string, nodeId: string): Promise<boolean>;
|
|
15
|
+
export declare function lockNode(projectId: string, nodeId: string, locked: boolean): Promise<NodeData | null>;
|
|
16
|
+
export declare function searchNodes(projectId: string, keywords: string[], limit?: number): Promise<SearchResult[]>;
|
|
17
|
+
export declare function findPath(projectId: string, startId: string, endId: string): Promise<PathResult | null>;
|
|
18
|
+
export interface OrphanNode {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
type: string;
|
|
22
|
+
status: string;
|
|
23
|
+
}
|
|
24
|
+
export interface RelationNode {
|
|
25
|
+
nodeId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
edgeType: string;
|
|
29
|
+
direction: 'outgoing' | 'incoming';
|
|
30
|
+
}
|
|
31
|
+
/** 查找孤立节点(没有任何边连接的节点) */
|
|
32
|
+
export declare function findOrphans(projectId: string): Promise<OrphanNode[]>;
|
|
33
|
+
/** 查询节点关系网(所有直接连接的节点) */
|
|
34
|
+
export declare function getRelations(projectId: string, nodeId: string): Promise<RelationNode[]>;
|
|
35
|
+
/** 添加错误修复记录到节点 */
|
|
36
|
+
export declare function addBugfix(projectId: string, nodeId: string, bugfix: {
|
|
37
|
+
issue: string;
|
|
38
|
+
solution: string;
|
|
39
|
+
impact?: string;
|
|
40
|
+
}): Promise<BugfixRecord | null>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import lockfile from 'proper-lockfile';
|
|
5
|
+
const DATA_DIR = path.join(homedir(), '.ppdocs');
|
|
6
|
+
// 文件锁配置
|
|
7
|
+
const LOCK_OPTIONS = {
|
|
8
|
+
stale: 10000, // 锁超时 10秒 (防止死锁)
|
|
9
|
+
retries: {
|
|
10
|
+
retries: 5, // 最多重试 5 次
|
|
11
|
+
minTimeout: 100, // 最小等待 100ms
|
|
12
|
+
maxTimeout: 500 // 最大等待 500ms
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
async function ensureDir(dir) {
|
|
16
|
+
await fs.mkdir(dir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
async function readJSON(filePath, defaultValue) {
|
|
19
|
+
try {
|
|
20
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
21
|
+
return JSON.parse(content);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return defaultValue;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// 带文件锁的写入 (防止并发数据丢失)
|
|
28
|
+
async function writeJSON(filePath, data) {
|
|
29
|
+
await ensureDir(path.dirname(filePath));
|
|
30
|
+
// 确保文件存在 (lockfile 需要文件存在)
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(filePath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await fs.writeFile(filePath, '{}');
|
|
36
|
+
}
|
|
37
|
+
let release = null;
|
|
38
|
+
try {
|
|
39
|
+
release = await lockfile.lock(filePath, LOCK_OPTIONS);
|
|
40
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2));
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (release)
|
|
44
|
+
await release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// ============ 项目操作 ============
|
|
48
|
+
export async function listProjects() {
|
|
49
|
+
const filePath = path.join(DATA_DIR, 'projects.json');
|
|
50
|
+
return readJSON(filePath, []);
|
|
51
|
+
}
|
|
52
|
+
export async function ensureProject(projectId) {
|
|
53
|
+
const projects = await listProjects();
|
|
54
|
+
const existing = projects.find(p => p.id === projectId);
|
|
55
|
+
if (existing)
|
|
56
|
+
return existing;
|
|
57
|
+
// 创建新项目
|
|
58
|
+
const project = { id: projectId, name: projectId, updatedAt: new Date().toISOString() };
|
|
59
|
+
projects.push(project);
|
|
60
|
+
await writeJSON(path.join(DATA_DIR, 'projects.json'), projects);
|
|
61
|
+
// 创建根节点
|
|
62
|
+
const rootNode = {
|
|
63
|
+
id: 'root',
|
|
64
|
+
title: projectId,
|
|
65
|
+
type: 'intro',
|
|
66
|
+
status: 'complete',
|
|
67
|
+
x: 0, y: 0, // 根节点为坐标原点
|
|
68
|
+
locked: true,
|
|
69
|
+
isOrigin: true,
|
|
70
|
+
signature: projectId,
|
|
71
|
+
categories: ['logic'], // 与 Frontend/Rust 统一
|
|
72
|
+
description: `# ${projectId}\n\n项目根节点`,
|
|
73
|
+
dependencies: []
|
|
74
|
+
};
|
|
75
|
+
const meta = { projectId, projectName: projectId, updatedAt: project.updatedAt, edges: [] };
|
|
76
|
+
const projectDir = path.join(DATA_DIR, 'projects', projectId);
|
|
77
|
+
await writeJSON(path.join(projectDir, 'meta.json'), meta);
|
|
78
|
+
await writeJSON(path.join(projectDir, 'nodes', 'root.json'), rootNode);
|
|
79
|
+
return project;
|
|
80
|
+
}
|
|
81
|
+
export async function createProject(name) {
|
|
82
|
+
const id = `project-${Date.now()}`;
|
|
83
|
+
return ensureProject(id);
|
|
84
|
+
}
|
|
85
|
+
// 自动计算边 (基于 dependencies.name 匹配 signature)
|
|
86
|
+
function computeAutoEdges(nodes) {
|
|
87
|
+
const signatureList = [];
|
|
88
|
+
nodes.forEach(n => {
|
|
89
|
+
if (n.signature) {
|
|
90
|
+
signatureList.push({ sig: n.signature.toLowerCase(), id: n.id });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const edges = [];
|
|
94
|
+
const edgeSet = new Set();
|
|
95
|
+
nodes.forEach(node => {
|
|
96
|
+
node.dependencies?.forEach(dep => {
|
|
97
|
+
const depName = dep.name.toLowerCase();
|
|
98
|
+
const match = signatureList.find(s => s.sig.startsWith(depName));
|
|
99
|
+
if (match && match.id !== node.id) {
|
|
100
|
+
const key = `${node.id}->${match.id}`;
|
|
101
|
+
if (!edgeSet.has(key)) {
|
|
102
|
+
edgeSet.add(key);
|
|
103
|
+
edges.push({ source: node.id, target: match.id, type: 'uses' });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
return edges;
|
|
109
|
+
}
|
|
110
|
+
function calculateAutoPosition(nodes, dependencies) {
|
|
111
|
+
const root = nodes.find(n => n.isOrigin);
|
|
112
|
+
if (!root)
|
|
113
|
+
return { x: 200, y: 200 };
|
|
114
|
+
// 无依赖 → 随机环绕 root
|
|
115
|
+
if (!dependencies?.length) {
|
|
116
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
117
|
+
const radius = 200 + Math.random() * 100;
|
|
118
|
+
return {
|
|
119
|
+
x: Math.round(root.x + Math.cos(angle) * radius),
|
|
120
|
+
y: Math.round(root.y + Math.sin(angle) * radius)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// 找距 root 最远的依赖节点作为 parent
|
|
124
|
+
let parent = null;
|
|
125
|
+
let maxDist = -1;
|
|
126
|
+
for (const dep of dependencies) {
|
|
127
|
+
const depName = dep.name.toLowerCase();
|
|
128
|
+
const match = nodes.find(n => n.signature?.toLowerCase().startsWith(depName));
|
|
129
|
+
if (match) {
|
|
130
|
+
const dist = Math.hypot(match.x - root.x, match.y - root.y);
|
|
131
|
+
if (dist > maxDist) {
|
|
132
|
+
maxDist = dist;
|
|
133
|
+
parent = match;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// 无匹配依赖 → 随机环绕 root
|
|
138
|
+
if (!parent) {
|
|
139
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
140
|
+
const radius = 200 + Math.random() * 100;
|
|
141
|
+
return {
|
|
142
|
+
x: Math.round(root.x + Math.cos(angle) * radius),
|
|
143
|
+
y: Math.round(root.y + Math.sin(angle) * radius)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// 计算方向向量 (root → parent)
|
|
147
|
+
let dx = parent.x - root.x;
|
|
148
|
+
let dy = parent.y - root.y;
|
|
149
|
+
const len = Math.hypot(dx, dy);
|
|
150
|
+
if (len < 10) {
|
|
151
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
152
|
+
dx = Math.cos(angle);
|
|
153
|
+
dy = Math.sin(angle);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
dx /= len;
|
|
157
|
+
dy /= len;
|
|
158
|
+
}
|
|
159
|
+
// 新位置 = parent + 方向 * 150 (加小随机偏移防重叠)
|
|
160
|
+
const offset = 150 + (Math.random() - 0.5) * 30;
|
|
161
|
+
const angleOffset = (Math.random() - 0.5) * 0.3;
|
|
162
|
+
const cos = Math.cos(angleOffset), sin = Math.sin(angleOffset);
|
|
163
|
+
return {
|
|
164
|
+
x: Math.round(parent.x + (dx * cos - dy * sin) * offset),
|
|
165
|
+
y: Math.round(parent.y + (dx * sin + dy * cos) * offset)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// ============ 节点操作 ============
|
|
169
|
+
function getNodePath(projectId, nodeId) {
|
|
170
|
+
return path.join(DATA_DIR, 'projects', projectId, 'nodes', `${nodeId}.json`);
|
|
171
|
+
}
|
|
172
|
+
function getMetaPath(projectId) {
|
|
173
|
+
return path.join(DATA_DIR, 'projects', projectId, 'meta.json');
|
|
174
|
+
}
|
|
175
|
+
function getChangeMarkerPath(projectId) {
|
|
176
|
+
return path.join(DATA_DIR, 'projects', projectId, '.changed');
|
|
177
|
+
}
|
|
178
|
+
// 更新变更标记 (前端用于检测 MCP 变更)
|
|
179
|
+
async function touchChangeMarker(projectId) {
|
|
180
|
+
const markerPath = getChangeMarkerPath(projectId);
|
|
181
|
+
await ensureDir(path.dirname(markerPath));
|
|
182
|
+
await fs.writeFile(markerPath, Date.now().toString());
|
|
183
|
+
}
|
|
184
|
+
// 获取变更标记时间戳 (前端轮询用)
|
|
185
|
+
export async function getChangeMarkerTime(projectId) {
|
|
186
|
+
const markerPath = getChangeMarkerPath(projectId);
|
|
187
|
+
try {
|
|
188
|
+
const content = await fs.readFile(markerPath, 'utf-8');
|
|
189
|
+
return parseInt(content, 10) || 0;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 更新节点的最后访问时间 (触发前端发光效果)
|
|
196
|
+
async function touchNode(projectId, nodeId) {
|
|
197
|
+
const nodePath = getNodePath(projectId, nodeId);
|
|
198
|
+
try {
|
|
199
|
+
const node = await readJSON(nodePath, null);
|
|
200
|
+
if (node) {
|
|
201
|
+
node.lastAccessedAt = new Date().toISOString();
|
|
202
|
+
await writeJSON(nodePath, node);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch { /* ignore */ }
|
|
206
|
+
}
|
|
207
|
+
// 批量更新访问时间 (同时更新变更标记)
|
|
208
|
+
async function touchNodes(projectId, nodeIds) {
|
|
209
|
+
await Promise.all(nodeIds.map(id => touchNode(projectId, id)));
|
|
210
|
+
await touchChangeMarker(projectId);
|
|
211
|
+
}
|
|
212
|
+
export async function listNodes(projectId) {
|
|
213
|
+
const nodesDir = path.join(DATA_DIR, 'projects', projectId, 'nodes');
|
|
214
|
+
try {
|
|
215
|
+
const files = await fs.readdir(nodesDir);
|
|
216
|
+
const nodes = [];
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
if (file.endsWith('.json')) {
|
|
219
|
+
const node = await readJSON(path.join(nodesDir, file), null);
|
|
220
|
+
if (node)
|
|
221
|
+
nodes.push(node);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return nodes;
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export async function getNode(projectId, nodeId) {
|
|
231
|
+
return readJSON(getNodePath(projectId, nodeId), null);
|
|
232
|
+
}
|
|
233
|
+
export async function createNode(projectId, node) {
|
|
234
|
+
const id = `node-${Date.now()}`;
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
// 自动布局: 未指定位置时计算
|
|
237
|
+
let x = node.x;
|
|
238
|
+
let y = node.y;
|
|
239
|
+
if (x === undefined || y === undefined) {
|
|
240
|
+
const nodes = await listNodes(projectId);
|
|
241
|
+
const pos = calculateAutoPosition(nodes, node.dependencies || []);
|
|
242
|
+
x = pos.x;
|
|
243
|
+
y = pos.y;
|
|
244
|
+
}
|
|
245
|
+
const fullNode = {
|
|
246
|
+
...node,
|
|
247
|
+
id,
|
|
248
|
+
x,
|
|
249
|
+
y,
|
|
250
|
+
updatedAt: now,
|
|
251
|
+
versions: [{ version: 1, date: now, changes: '初始创建' }],
|
|
252
|
+
bugfixes: []
|
|
253
|
+
};
|
|
254
|
+
await writeJSON(getNodePath(projectId, id), fullNode);
|
|
255
|
+
await touchChangeMarker(projectId);
|
|
256
|
+
return fullNode;
|
|
257
|
+
}
|
|
258
|
+
export async function updateNode(projectId, nodeId, updates) {
|
|
259
|
+
const node = await readJSON(getNodePath(projectId, nodeId), null);
|
|
260
|
+
if (!node)
|
|
261
|
+
return null;
|
|
262
|
+
if (node.locked && !('locked' in updates))
|
|
263
|
+
return null; // 锁定节点只能改locked
|
|
264
|
+
const now = new Date().toISOString();
|
|
265
|
+
// 自动检测变更字段
|
|
266
|
+
const changes = [];
|
|
267
|
+
if (updates.title !== undefined && updates.title !== node.title)
|
|
268
|
+
changes.push('标题');
|
|
269
|
+
if (updates.description !== undefined && updates.description !== node.description)
|
|
270
|
+
changes.push('描述');
|
|
271
|
+
if (updates.status !== undefined && updates.status !== node.status)
|
|
272
|
+
changes.push('状态');
|
|
273
|
+
if (updates.categories !== undefined && JSON.stringify(updates.categories) !== JSON.stringify(node.categories))
|
|
274
|
+
changes.push('分类');
|
|
275
|
+
if (updates.dependencies !== undefined && JSON.stringify(updates.dependencies) !== JSON.stringify(node.dependencies))
|
|
276
|
+
changes.push('依赖');
|
|
277
|
+
if (updates.signature !== undefined && updates.signature !== node.signature)
|
|
278
|
+
changes.push('签名');
|
|
279
|
+
// 合并更新
|
|
280
|
+
const updated = { ...node, ...updates, updatedAt: now };
|
|
281
|
+
// 有变更时自动追加版本记录
|
|
282
|
+
if (changes.length > 0) {
|
|
283
|
+
const currentVersion = node.versions?.[0]?.version || 0;
|
|
284
|
+
const newVersion = {
|
|
285
|
+
version: currentVersion + 1,
|
|
286
|
+
date: now,
|
|
287
|
+
changes: `更新: ${changes.join(', ')}`
|
|
288
|
+
};
|
|
289
|
+
updated.versions = [newVersion, ...(node.versions || [])].slice(0, 10);
|
|
290
|
+
}
|
|
291
|
+
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
292
|
+
await touchChangeMarker(projectId);
|
|
293
|
+
return updated;
|
|
294
|
+
}
|
|
295
|
+
export async function deleteNode(projectId, nodeId) {
|
|
296
|
+
const node = await getNode(projectId, nodeId);
|
|
297
|
+
if (!node || node.isOrigin || node.locked)
|
|
298
|
+
return false;
|
|
299
|
+
try {
|
|
300
|
+
await fs.unlink(getNodePath(projectId, nodeId));
|
|
301
|
+
await touchChangeMarker(projectId);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export async function lockNode(projectId, nodeId, locked) {
|
|
309
|
+
const node = await getNode(projectId, nodeId);
|
|
310
|
+
if (!node || node.isOrigin)
|
|
311
|
+
return null;
|
|
312
|
+
const updated = { ...node, locked };
|
|
313
|
+
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
314
|
+
await touchChangeMarker(projectId);
|
|
315
|
+
return updated;
|
|
316
|
+
}
|
|
317
|
+
// ============ 搜索 ============
|
|
318
|
+
export async function searchNodes(projectId, keywords, limit = 20) {
|
|
319
|
+
const nodes = await listNodes(projectId);
|
|
320
|
+
const results = [];
|
|
321
|
+
for (const node of nodes) {
|
|
322
|
+
let score = 0;
|
|
323
|
+
const matches = [];
|
|
324
|
+
const lowerKeywords = keywords.map(k => k.toLowerCase());
|
|
325
|
+
for (const keyword of lowerKeywords) {
|
|
326
|
+
if (node.title?.toLowerCase().includes(keyword)) {
|
|
327
|
+
score += 3;
|
|
328
|
+
matches.push(`title: ${node.title}`);
|
|
329
|
+
}
|
|
330
|
+
if (node.signature?.toLowerCase().includes(keyword)) {
|
|
331
|
+
score += 2;
|
|
332
|
+
matches.push(`signature: ${node.signature}`);
|
|
333
|
+
}
|
|
334
|
+
if (node.categories?.some(t => t.toLowerCase().includes(keyword))) {
|
|
335
|
+
score += 2.5;
|
|
336
|
+
matches.push(`categories: ${node.categories.join(', ')}`);
|
|
337
|
+
}
|
|
338
|
+
if (node.description?.toLowerCase().includes(keyword)) {
|
|
339
|
+
score += 1;
|
|
340
|
+
matches.push('description');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (score > 0)
|
|
344
|
+
results.push({ node, score, matches: [...new Set(matches)] });
|
|
345
|
+
}
|
|
346
|
+
const finalResults = results.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
347
|
+
// 触发发光效果
|
|
348
|
+
await touchNodes(projectId, finalResults.map(r => r.node.id));
|
|
349
|
+
return finalResults;
|
|
350
|
+
}
|
|
351
|
+
// ============ 路径查找 (BFS) ============
|
|
352
|
+
export async function findPath(projectId, startId, endId) {
|
|
353
|
+
const nodes = await listNodes(projectId);
|
|
354
|
+
const edges = computeAutoEdges(nodes);
|
|
355
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
356
|
+
if (!nodeMap.has(startId) || !nodeMap.has(endId))
|
|
357
|
+
return null;
|
|
358
|
+
// 建立邻接表 (双向)
|
|
359
|
+
const adj = new Map();
|
|
360
|
+
for (const edge of edges) {
|
|
361
|
+
if (!adj.has(edge.source))
|
|
362
|
+
adj.set(edge.source, []);
|
|
363
|
+
if (!adj.has(edge.target))
|
|
364
|
+
adj.set(edge.target, []);
|
|
365
|
+
adj.get(edge.source).push({ nodeId: edge.target, edge });
|
|
366
|
+
adj.get(edge.target).push({ nodeId: edge.source, edge });
|
|
367
|
+
}
|
|
368
|
+
// BFS
|
|
369
|
+
const visited = new Set([startId]);
|
|
370
|
+
const queue = [{ nodeId: startId, path: [startId], edges: [] }];
|
|
371
|
+
while (queue.length > 0) {
|
|
372
|
+
const { nodeId, path, edges: pathEdges } = queue.shift();
|
|
373
|
+
if (nodeId === endId) {
|
|
374
|
+
// 触发发光效果
|
|
375
|
+
await touchNodes(projectId, path);
|
|
376
|
+
return { path: path.map(id => nodeMap.get(id)), edges: pathEdges };
|
|
377
|
+
}
|
|
378
|
+
for (const { nodeId: nextId, edge } of adj.get(nodeId) || []) {
|
|
379
|
+
if (!visited.has(nextId)) {
|
|
380
|
+
visited.add(nextId);
|
|
381
|
+
queue.push({ nodeId: nextId, path: [...path, nextId], edges: [...pathEdges, edge] });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
/** 查找孤立节点(没有任何边连接的节点) */
|
|
388
|
+
export async function findOrphans(projectId) {
|
|
389
|
+
const nodes = await listNodes(projectId);
|
|
390
|
+
const edges = computeAutoEdges(nodes);
|
|
391
|
+
// 收集所有有边连接的节点ID
|
|
392
|
+
const connected = new Set();
|
|
393
|
+
for (const edge of edges) {
|
|
394
|
+
connected.add(edge.source);
|
|
395
|
+
connected.add(edge.target);
|
|
396
|
+
}
|
|
397
|
+
// 过滤出孤立节点
|
|
398
|
+
return nodes
|
|
399
|
+
.filter(n => !connected.has(n.id))
|
|
400
|
+
.map(n => ({ id: n.id, title: n.title, type: n.type, status: n.status }));
|
|
401
|
+
}
|
|
402
|
+
/** 查询节点关系网(所有直接连接的节点) */
|
|
403
|
+
export async function getRelations(projectId, nodeId) {
|
|
404
|
+
const nodes = await listNodes(projectId);
|
|
405
|
+
const edges = computeAutoEdges(nodes);
|
|
406
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
407
|
+
const relations = [];
|
|
408
|
+
for (const edge of edges) {
|
|
409
|
+
let relatedId = null;
|
|
410
|
+
let direction = null;
|
|
411
|
+
if (edge.source === nodeId) {
|
|
412
|
+
relatedId = edge.target;
|
|
413
|
+
direction = 'outgoing';
|
|
414
|
+
}
|
|
415
|
+
else if (edge.target === nodeId) {
|
|
416
|
+
relatedId = edge.source;
|
|
417
|
+
direction = 'incoming';
|
|
418
|
+
}
|
|
419
|
+
if (relatedId && direction) {
|
|
420
|
+
const node = nodeMap.get(relatedId);
|
|
421
|
+
if (node) {
|
|
422
|
+
relations.push({
|
|
423
|
+
nodeId: relatedId,
|
|
424
|
+
title: node.title,
|
|
425
|
+
description: node.description.slice(0, 100),
|
|
426
|
+
edgeType: edge.type,
|
|
427
|
+
direction
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// 触发发光效果 (当前节点 + 所有关联节点)
|
|
433
|
+
const touchList = [nodeId, ...relations.map(r => r.nodeId)];
|
|
434
|
+
await touchNodes(projectId, touchList);
|
|
435
|
+
return relations;
|
|
436
|
+
}
|
|
437
|
+
// ============ 错误修复记录 ============
|
|
438
|
+
/** 添加错误修复记录到节点 */
|
|
439
|
+
export async function addBugfix(projectId, nodeId, bugfix) {
|
|
440
|
+
const node = await getNode(projectId, nodeId);
|
|
441
|
+
if (!node || node.locked)
|
|
442
|
+
return null;
|
|
443
|
+
const newBugfix = {
|
|
444
|
+
id: `bug-${Date.now()}`,
|
|
445
|
+
date: new Date().toISOString(),
|
|
446
|
+
issue: bugfix.issue,
|
|
447
|
+
solution: bugfix.solution,
|
|
448
|
+
impact: bugfix.impact
|
|
449
|
+
};
|
|
450
|
+
const updated = {
|
|
451
|
+
...node,
|
|
452
|
+
bugfixes: [newBugfix, ...(node.bugfixes || [])].slice(0, 20), // 保留最近 20 条
|
|
453
|
+
updatedAt: new Date().toISOString()
|
|
454
|
+
};
|
|
455
|
+
await writeJSON(getNodePath(projectId, nodeId), updated);
|
|
456
|
+
await touchChangeMarker(projectId);
|
|
457
|
+
return newBugfix;
|
|
458
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ppdocs MCP HTTP API Client
|
|
3
|
+
* 通过 HTTP API 调用主程序,替代直接文件访问
|
|
4
|
+
*
|
|
5
|
+
* API URL 格式: http://localhost:20001/api/:projectId/:password/...
|
|
6
|
+
*/
|
|
7
|
+
import type { NodeData, SearchResult, PathResult, BugfixRecord } from './types.js';
|
|
8
|
+
export declare class PpdocsApiClient {
|
|
9
|
+
private baseUrl;
|
|
10
|
+
constructor(apiUrl: string);
|
|
11
|
+
private request;
|
|
12
|
+
listNodes(): Promise<NodeData[]>;
|
|
13
|
+
getNode(nodeId: string): Promise<NodeData | null>;
|
|
14
|
+
createNode(node: Partial<NodeData>): Promise<NodeData>;
|
|
15
|
+
updateNode(nodeId: string, updates: Partial<NodeData>): Promise<NodeData | null>;
|
|
16
|
+
deleteNode(nodeId: string): Promise<boolean>;
|
|
17
|
+
lockNode(nodeId: string, locked: boolean): Promise<NodeData | null>;
|
|
18
|
+
searchNodes(keywords: string[], limit?: number): Promise<SearchResult[]>;
|
|
19
|
+
findPath(startId: string, endId: string): Promise<PathResult | null>;
|
|
20
|
+
findOrphans(): Promise<Array<{
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
type: string;
|
|
24
|
+
status: string;
|
|
25
|
+
}>>;
|
|
26
|
+
getRelations(nodeId: string): Promise<Array<{
|
|
27
|
+
nodeId: string;
|
|
28
|
+
title: string;
|
|
29
|
+
description: string;
|
|
30
|
+
edgeType: string;
|
|
31
|
+
direction: 'outgoing' | 'incoming';
|
|
32
|
+
}>>;
|
|
33
|
+
addBugfix(nodeId: string, bugfix: {
|
|
34
|
+
issue: string;
|
|
35
|
+
solution: string;
|
|
36
|
+
impact?: string;
|
|
37
|
+
}): Promise<BugfixRecord | null>;
|
|
38
|
+
}
|
|
39
|
+
export declare function initClient(apiUrl: string): void;
|
|
40
|
+
export declare function listNodes(_projectId: string): Promise<NodeData[]>;
|
|
41
|
+
export declare function getNode(_projectId: string, nodeId: string): Promise<NodeData | null>;
|
|
42
|
+
export declare function createNode(_projectId: string, node: Partial<NodeData>): Promise<NodeData>;
|
|
43
|
+
export declare function updateNode(_projectId: string, nodeId: string, updates: Partial<NodeData>): Promise<NodeData | null>;
|
|
44
|
+
export declare function deleteNode(_projectId: string, nodeId: string): Promise<boolean>;
|
|
45
|
+
export declare function lockNode(_projectId: string, nodeId: string, locked: boolean): Promise<NodeData | null>;
|
|
46
|
+
export declare function searchNodes(_projectId: string, keywords: string[], limit?: number): Promise<SearchResult[]>;
|
|
47
|
+
export declare function findPath(_projectId: string, startId: string, endId: string): Promise<PathResult | null>;
|
|
48
|
+
export declare function findOrphans(_projectId: string): Promise<Array<{
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
type: string;
|
|
52
|
+
status: string;
|
|
53
|
+
}>>;
|
|
54
|
+
export declare function getRelations(_projectId: string, nodeId: string): Promise<Array<{
|
|
55
|
+
nodeId: string;
|
|
56
|
+
title: string;
|
|
57
|
+
description: string;
|
|
58
|
+
edgeType: string;
|
|
59
|
+
direction: 'outgoing' | 'incoming';
|
|
60
|
+
}>>;
|
|
61
|
+
export declare function addBugfix(_projectId: string, nodeId: string, bugfix: {
|
|
62
|
+
issue: string;
|
|
63
|
+
solution: string;
|
|
64
|
+
impact?: string;
|
|
65
|
+
}): Promise<BugfixRecord | null>;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ppdocs MCP HTTP API Client
|
|
3
|
+
* 通过 HTTP API 调用主程序,替代直接文件访问
|
|
4
|
+
*
|
|
5
|
+
* API URL 格式: http://localhost:20001/api/:projectId/:password/...
|
|
6
|
+
*/
|
|
7
|
+
// API 客户端类
|
|
8
|
+
export class PpdocsApiClient {
|
|
9
|
+
baseUrl; // http://localhost:20001/api/projectId/password
|
|
10
|
+
constructor(apiUrl) {
|
|
11
|
+
// 移除末尾斜杠
|
|
12
|
+
this.baseUrl = apiUrl.replace(/\/$/, '');
|
|
13
|
+
}
|
|
14
|
+
// ============ HTTP 请求工具 ============
|
|
15
|
+
async request(path, options = {}) {
|
|
16
|
+
const url = `${this.baseUrl}${path}`;
|
|
17
|
+
const response = await fetch(url, {
|
|
18
|
+
...options,
|
|
19
|
+
headers: {
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
...options.headers
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
const error = await response.json().catch(() => ({ error: response.statusText }));
|
|
26
|
+
throw new Error(error.error || `HTTP ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
const json = await response.json();
|
|
29
|
+
if (!json.success) {
|
|
30
|
+
throw new Error(json.error || 'Unknown error');
|
|
31
|
+
}
|
|
32
|
+
return json.data;
|
|
33
|
+
}
|
|
34
|
+
// ============ 节点操作 ============
|
|
35
|
+
async listNodes() {
|
|
36
|
+
return this.request('/nodes');
|
|
37
|
+
}
|
|
38
|
+
async getNode(nodeId) {
|
|
39
|
+
try {
|
|
40
|
+
return await this.request(`/nodes/${nodeId}`);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async createNode(node) {
|
|
47
|
+
// 自动布局: 未指定位置时服务端会计算
|
|
48
|
+
const payload = {
|
|
49
|
+
id: '', // 服务端自动生成
|
|
50
|
+
title: node.title || '',
|
|
51
|
+
type: node.type || 'logic',
|
|
52
|
+
status: node.status || 'incomplete',
|
|
53
|
+
x: node.x ?? 0,
|
|
54
|
+
y: node.y ?? 0,
|
|
55
|
+
locked: false,
|
|
56
|
+
signature: node.signature || node.title || '',
|
|
57
|
+
categories: node.categories || [],
|
|
58
|
+
description: node.description || '',
|
|
59
|
+
dependencies: node.dependencies || [],
|
|
60
|
+
relatedFiles: [],
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
lastAccessedAt: new Date().toISOString(),
|
|
64
|
+
versions: [],
|
|
65
|
+
bugfixes: []
|
|
66
|
+
};
|
|
67
|
+
return this.request('/nodes', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
body: JSON.stringify(payload)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async updateNode(nodeId, updates) {
|
|
73
|
+
// 先获取现有节点,合并更新
|
|
74
|
+
const existing = await this.getNode(nodeId);
|
|
75
|
+
if (!existing)
|
|
76
|
+
return null;
|
|
77
|
+
const payload = {
|
|
78
|
+
...existing,
|
|
79
|
+
...updates,
|
|
80
|
+
updatedAt: new Date().toISOString()
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
return await this.request(`/nodes/${nodeId}`, {
|
|
84
|
+
method: 'PUT',
|
|
85
|
+
body: JSON.stringify(payload)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async deleteNode(nodeId) {
|
|
93
|
+
try {
|
|
94
|
+
await this.request(`/nodes/${nodeId}`, { method: 'DELETE' });
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async lockNode(nodeId, locked) {
|
|
102
|
+
try {
|
|
103
|
+
return await this.request(`/nodes/${nodeId}/lock`, {
|
|
104
|
+
method: 'PUT',
|
|
105
|
+
body: JSON.stringify({ locked })
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ============ 搜索与路径 ============
|
|
113
|
+
async searchNodes(keywords, limit = 20) {
|
|
114
|
+
return this.request('/search', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: JSON.stringify({ keywords, limit })
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async findPath(startId, endId) {
|
|
120
|
+
try {
|
|
121
|
+
return await this.request('/path', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
body: JSON.stringify({ start_id: startId, end_id: endId })
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async findOrphans() {
|
|
131
|
+
return this.request('/orphans');
|
|
132
|
+
}
|
|
133
|
+
async getRelations(nodeId) {
|
|
134
|
+
const result = await this.request(`/relations/${nodeId}`);
|
|
135
|
+
// 转换字段名 (API 返回 type,前端期望 edgeType)
|
|
136
|
+
return result.map(r => ({
|
|
137
|
+
nodeId: r.nodeId,
|
|
138
|
+
title: r.title,
|
|
139
|
+
description: r.description,
|
|
140
|
+
edgeType: r.type,
|
|
141
|
+
direction: r.direction
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
// ============ Bugfix ============
|
|
145
|
+
async addBugfix(nodeId, bugfix) {
|
|
146
|
+
// 添加 bugfix 需要先获取节点,追加 bugfix,然后更新
|
|
147
|
+
const node = await this.getNode(nodeId);
|
|
148
|
+
if (!node || node.locked)
|
|
149
|
+
return null;
|
|
150
|
+
const newBugfix = {
|
|
151
|
+
id: `bug-${Date.now()}`,
|
|
152
|
+
date: new Date().toISOString(),
|
|
153
|
+
issue: bugfix.issue,
|
|
154
|
+
solution: bugfix.solution,
|
|
155
|
+
impact: bugfix.impact
|
|
156
|
+
};
|
|
157
|
+
const updated = await this.updateNode(nodeId, {
|
|
158
|
+
bugfixes: [newBugfix, ...(node.bugfixes || [])].slice(0, 20)
|
|
159
|
+
});
|
|
160
|
+
return updated ? newBugfix : null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// ============ 模块级 API (兼容现有 tools/index.ts) ============
|
|
164
|
+
let client = null;
|
|
165
|
+
export function initClient(apiUrl) {
|
|
166
|
+
client = new PpdocsApiClient(apiUrl);
|
|
167
|
+
}
|
|
168
|
+
function getClient() {
|
|
169
|
+
if (!client) {
|
|
170
|
+
throw new Error('API client not initialized. Call initClient(apiUrl) first.');
|
|
171
|
+
}
|
|
172
|
+
return client;
|
|
173
|
+
}
|
|
174
|
+
// 导出与 fileStorage 相同的函数签名
|
|
175
|
+
export async function listNodes(_projectId) {
|
|
176
|
+
return getClient().listNodes();
|
|
177
|
+
}
|
|
178
|
+
export async function getNode(_projectId, nodeId) {
|
|
179
|
+
return getClient().getNode(nodeId);
|
|
180
|
+
}
|
|
181
|
+
export async function createNode(_projectId, node) {
|
|
182
|
+
return getClient().createNode(node);
|
|
183
|
+
}
|
|
184
|
+
export async function updateNode(_projectId, nodeId, updates) {
|
|
185
|
+
return getClient().updateNode(nodeId, updates);
|
|
186
|
+
}
|
|
187
|
+
export async function deleteNode(_projectId, nodeId) {
|
|
188
|
+
return getClient().deleteNode(nodeId);
|
|
189
|
+
}
|
|
190
|
+
export async function lockNode(_projectId, nodeId, locked) {
|
|
191
|
+
return getClient().lockNode(nodeId, locked);
|
|
192
|
+
}
|
|
193
|
+
export async function searchNodes(_projectId, keywords, limit) {
|
|
194
|
+
return getClient().searchNodes(keywords, limit);
|
|
195
|
+
}
|
|
196
|
+
export async function findPath(_projectId, startId, endId) {
|
|
197
|
+
return getClient().findPath(startId, endId);
|
|
198
|
+
}
|
|
199
|
+
export async function findOrphans(_projectId) {
|
|
200
|
+
return getClient().findOrphans();
|
|
201
|
+
}
|
|
202
|
+
export async function getRelations(_projectId, nodeId) {
|
|
203
|
+
return getClient().getRelations(nodeId);
|
|
204
|
+
}
|
|
205
|
+
export async function addBugfix(_projectId, nodeId, bugfix) {
|
|
206
|
+
return getClient().addBugfix(nodeId, bugfix);
|
|
207
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type EdgeType = 'contains' | 'uses' | 'extends' | 'implements';
|
|
2
|
+
export interface Edge {
|
|
3
|
+
source: string;
|
|
4
|
+
target: string;
|
|
5
|
+
type: EdgeType;
|
|
6
|
+
auto?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type NodeType = 'logic' | 'data' | 'intro';
|
|
9
|
+
export type NodeStatus = 'incomplete' | 'complete' | 'fixing' | 'refactoring' | 'deprecated' | 'locked';
|
|
10
|
+
export interface DataRef {
|
|
11
|
+
type: string;
|
|
12
|
+
description: string;
|
|
13
|
+
formatPath?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface Dependency {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
nodePath?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface VersionRecord {
|
|
21
|
+
version: number;
|
|
22
|
+
date: string;
|
|
23
|
+
changes: string;
|
|
24
|
+
}
|
|
25
|
+
export interface BugfixRecord {
|
|
26
|
+
id: string;
|
|
27
|
+
date: string;
|
|
28
|
+
issue: string;
|
|
29
|
+
solution: string;
|
|
30
|
+
impact?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface NodeData {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
type: NodeType;
|
|
36
|
+
status: NodeStatus;
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
locked: boolean;
|
|
40
|
+
isOrigin?: boolean;
|
|
41
|
+
signature: string;
|
|
42
|
+
categories: string[];
|
|
43
|
+
description: string;
|
|
44
|
+
dataInput?: DataRef;
|
|
45
|
+
dataOutput?: DataRef;
|
|
46
|
+
dependencies: Dependency[];
|
|
47
|
+
relatedFiles?: string[];
|
|
48
|
+
createdAt?: string;
|
|
49
|
+
updatedAt?: string;
|
|
50
|
+
lastAccessedAt?: string;
|
|
51
|
+
versions?: VersionRecord[];
|
|
52
|
+
bugfixes?: BugfixRecord[];
|
|
53
|
+
}
|
|
54
|
+
export interface ProjectMeta {
|
|
55
|
+
projectId: string;
|
|
56
|
+
projectName: string;
|
|
57
|
+
updatedAt: string;
|
|
58
|
+
edges: Edge[];
|
|
59
|
+
}
|
|
60
|
+
export interface Project {
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
updatedAt: string;
|
|
64
|
+
}
|
|
65
|
+
export interface SearchResult {
|
|
66
|
+
node: NodeData;
|
|
67
|
+
score: number;
|
|
68
|
+
matches: string[];
|
|
69
|
+
}
|
|
70
|
+
export interface PathResult {
|
|
71
|
+
path: NodeData[];
|
|
72
|
+
edges: Edge[];
|
|
73
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as storage from '../storage/httpClient.js';
|
|
3
|
+
export function registerTools(server, projectId) {
|
|
4
|
+
// 1. 创建节点
|
|
5
|
+
server.tool('kg_create_node', '创建新节点', {
|
|
6
|
+
title: z.string().describe('节点标题'),
|
|
7
|
+
type: z.enum(['logic', 'data', 'intro']).describe('节点类型'),
|
|
8
|
+
description: z.string().describe(`节点描述(Markdown格式)。要求使用可视化元素:
|
|
9
|
+
|
|
10
|
+
1. **流程图用Mermaid**:
|
|
11
|
+
\`\`\`mermaid
|
|
12
|
+
graph LR
|
|
13
|
+
A[输入] --> B{判断} --> C[输出]
|
|
14
|
+
\`\`\`
|
|
15
|
+
|
|
16
|
+
2. **接口用表格**:
|
|
17
|
+
| 参数 | 类型 | 说明 |
|
|
18
|
+
|-----|------|-----|
|
|
19
|
+
| id | string | 唯一标识 |
|
|
20
|
+
|
|
21
|
+
3. **禁止纯文字堆砌**`),
|
|
22
|
+
signature: z.string().optional().describe('方法签名(用于自动关联,默认=title)'),
|
|
23
|
+
x: z.number().optional().describe('X坐标'),
|
|
24
|
+
y: z.number().optional().describe('Y坐标'),
|
|
25
|
+
tags: z.array(z.string()).optional().describe('标签列表'),
|
|
26
|
+
dependencies: z.array(z.object({
|
|
27
|
+
name: z.string().describe('依赖名称(匹配其他节点signature)'),
|
|
28
|
+
description: z.string().describe('依赖说明')
|
|
29
|
+
})).optional().describe('依赖列表(自动渲染连线)')
|
|
30
|
+
}, async (args) => {
|
|
31
|
+
const node = await storage.createNode(projectId, {
|
|
32
|
+
title: args.title,
|
|
33
|
+
type: args.type,
|
|
34
|
+
status: 'incomplete',
|
|
35
|
+
description: args.description || '',
|
|
36
|
+
x: args.x, // undefined 时由 fileStorage 自动布局
|
|
37
|
+
y: args.y,
|
|
38
|
+
locked: false,
|
|
39
|
+
signature: args.signature || args.title,
|
|
40
|
+
categories: args.tags || [], // API 参数名保持 tags,内部存储为 categories
|
|
41
|
+
dependencies: args.dependencies || []
|
|
42
|
+
});
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(node, null, 2) }] };
|
|
44
|
+
});
|
|
45
|
+
// 2. 删除节点
|
|
46
|
+
server.tool('kg_delete_node', '删除节点(锁定节点和根节点不可删除)', { nodeId: z.string().describe('节点ID') }, async (args) => {
|
|
47
|
+
const success = await storage.deleteNode(projectId, args.nodeId);
|
|
48
|
+
return { content: [{ type: 'text', text: success ? '删除成功' : '删除失败(节点不存在/已锁定/是根节点)' }] };
|
|
49
|
+
});
|
|
50
|
+
// 3. 更新节点
|
|
51
|
+
server.tool('kg_update_node', '更新节点(锁定节点不可更新)', {
|
|
52
|
+
nodeId: z.string().describe('节点ID'),
|
|
53
|
+
title: z.string().optional().describe('新标题'),
|
|
54
|
+
signature: z.string().optional().describe('新方法签名(用于自动关联)'),
|
|
55
|
+
description: z.string().optional().describe('新描述(Markdown格式,用Mermaid流程图+表格,禁止纯文字)'),
|
|
56
|
+
status: z.enum(['incomplete', 'complete', 'fixing', 'refactoring', 'deprecated']).optional(),
|
|
57
|
+
tags: z.array(z.string()).optional(),
|
|
58
|
+
dependencies: z.array(z.object({
|
|
59
|
+
name: z.string().describe('依赖名称'),
|
|
60
|
+
description: z.string().describe('依赖说明')
|
|
61
|
+
})).optional().describe('依赖列表')
|
|
62
|
+
}, async (args) => {
|
|
63
|
+
const { nodeId, tags, ...rest } = args;
|
|
64
|
+
// API 参数 tags 转换为内部字段 categories
|
|
65
|
+
const updates = tags !== undefined ? { ...rest, categories: tags } : rest;
|
|
66
|
+
const node = await storage.updateNode(projectId, nodeId, updates);
|
|
67
|
+
return { content: [{ type: 'text', text: node ? JSON.stringify(node, null, 2) : '更新失败(节点不存在或已锁定)' }] };
|
|
68
|
+
});
|
|
69
|
+
// 4. 锁定/解锁节点
|
|
70
|
+
server.tool('kg_lock_node', '锁定/解锁节点(锁定后只能读取)', {
|
|
71
|
+
nodeId: z.string().describe('节点ID'),
|
|
72
|
+
locked: z.boolean().describe('true=锁定, false=解锁')
|
|
73
|
+
}, async (args) => {
|
|
74
|
+
const node = await storage.lockNode(projectId, args.nodeId, args.locked);
|
|
75
|
+
return { content: [{ type: 'text', text: node ? JSON.stringify(node, null, 2) : '操作失败' }] };
|
|
76
|
+
});
|
|
77
|
+
// 5. 搜索节点
|
|
78
|
+
server.tool('kg_search', '多关键词搜索节点(OR逻辑),按权重排序(title:3>tags:2.5>signature:2>desc:1)', {
|
|
79
|
+
keywords: z.array(z.string()).describe('搜索关键词列表'),
|
|
80
|
+
limit: z.number().optional().describe('返回数量限制(默认20)')
|
|
81
|
+
}, async (args) => {
|
|
82
|
+
const results = await storage.searchNodes(projectId, args.keywords, args.limit);
|
|
83
|
+
const output = results.map(r => ({
|
|
84
|
+
id: r.node.id, title: r.node.title, type: r.node.type,
|
|
85
|
+
status: r.node.status, score: r.score.toFixed(1), matches: r.matches
|
|
86
|
+
}));
|
|
87
|
+
return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
|
|
88
|
+
});
|
|
89
|
+
// 6. 路径查找
|
|
90
|
+
server.tool('kg_find_path', '查找两个节点之间的逻辑流路径,返回中间所有节点和边', {
|
|
91
|
+
startId: z.string().describe('起始节点ID'),
|
|
92
|
+
endId: z.string().describe('目标节点ID')
|
|
93
|
+
}, async (args) => {
|
|
94
|
+
const result = await storage.findPath(projectId, args.startId, args.endId);
|
|
95
|
+
if (!result)
|
|
96
|
+
return { content: [{ type: 'text', text: '未找到路径' }] };
|
|
97
|
+
const output = {
|
|
98
|
+
pathLength: result.path.length,
|
|
99
|
+
nodes: result.path.map(n => ({ id: n.id, title: n.title, type: n.type })),
|
|
100
|
+
edges: result.edges.map(e => ({ from: e.source, to: e.target, type: e.type }))
|
|
101
|
+
};
|
|
102
|
+
return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
|
|
103
|
+
});
|
|
104
|
+
// 7. 列出所有节点
|
|
105
|
+
server.tool('kg_list_nodes', '列出当前项目所有节点(返回id,title,type,status,locked)', {}, async () => {
|
|
106
|
+
const nodes = await storage.listNodes(projectId);
|
|
107
|
+
const output = nodes.map(n => ({ id: n.id, title: n.title, type: n.type, status: n.status, locked: n.locked }));
|
|
108
|
+
return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
|
|
109
|
+
});
|
|
110
|
+
// 8. 查找孤立节点
|
|
111
|
+
server.tool('kg_find_orphans', '查找孤立节点(没有任何连线的节点)', {}, async () => {
|
|
112
|
+
const orphans = await storage.findOrphans(projectId);
|
|
113
|
+
if (orphans.length === 0) {
|
|
114
|
+
return { content: [{ type: 'text', text: '没有孤立节点' }] };
|
|
115
|
+
}
|
|
116
|
+
return { content: [{ type: 'text', text: JSON.stringify(orphans, null, 2) }] };
|
|
117
|
+
});
|
|
118
|
+
// 9. 查询节点关系网
|
|
119
|
+
server.tool('kg_get_relations', '查询节点关系网(所有直接连接的节点及其介绍)', {
|
|
120
|
+
nodeId: z.string().describe('节点ID')
|
|
121
|
+
}, async (args) => {
|
|
122
|
+
const relations = await storage.getRelations(projectId, args.nodeId);
|
|
123
|
+
if (relations.length === 0) {
|
|
124
|
+
return { content: [{ type: 'text', text: '该节点没有任何连线' }] };
|
|
125
|
+
}
|
|
126
|
+
// 分组显示
|
|
127
|
+
const outgoing = relations.filter(r => r.direction === 'outgoing');
|
|
128
|
+
const incoming = relations.filter(r => r.direction === 'incoming');
|
|
129
|
+
const output = {
|
|
130
|
+
outgoing: outgoing.map(r => ({ id: r.nodeId, title: r.title, desc: r.description, edge: r.edgeType })),
|
|
131
|
+
incoming: incoming.map(r => ({ id: r.nodeId, title: r.title, desc: r.description, edge: r.edgeType }))
|
|
132
|
+
};
|
|
133
|
+
return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
|
|
134
|
+
});
|
|
135
|
+
// 10. 添加错误修复记录
|
|
136
|
+
server.tool('kg_add_bugfix', '添加错误修复记录到节点(自动生成ID和时间戳)', {
|
|
137
|
+
nodeId: z.string().describe('节点ID'),
|
|
138
|
+
issue: z.string().describe('问题描述'),
|
|
139
|
+
solution: z.string().describe('解决方案'),
|
|
140
|
+
impact: z.string().optional().describe('影响范围')
|
|
141
|
+
}, async (args) => {
|
|
142
|
+
const bugfix = await storage.addBugfix(projectId, args.nodeId, {
|
|
143
|
+
issue: args.issue,
|
|
144
|
+
solution: args.solution,
|
|
145
|
+
impact: args.impact
|
|
146
|
+
});
|
|
147
|
+
return { content: [{ type: 'text', text: bugfix ? JSON.stringify(bugfix, null, 2) : '添加失败(节点不存在或已锁定)' }] };
|
|
148
|
+
});
|
|
149
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ppdocs/mcp",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "ppdocs MCP Server - Knowledge Graph for Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ppdocs-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["mcp", "claude", "knowledge-graph", "ppdocs"],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/ppdocs/ppdocs"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": ["dist", "README.md"],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
23
|
+
"proper-lockfile": "^4.1.2",
|
|
24
|
+
"zod": "^4.1.13"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
29
|
+
"typescript": "^5.7.0"
|
|
30
|
+
}
|
|
31
|
+
}
|