local-knowledge-graph 1.7.0 → 1.8.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/HELP.md +11 -0
- package/bin/cli.js +9 -0
- package/lib/bus.js +18 -0
- package/lib/embeddings.js +3 -0
- package/mcp/core.js +258 -0
- package/mcp/http.js +82 -0
- package/mcp/server.js +14 -241
- package/package.json +1 -1
- package/public/app.js +83 -0
- package/public/index.html +55 -0
- package/public/style.css +9 -0
- package/server.js +89 -0
package/HELP.md
CHANGED
|
@@ -65,6 +65,16 @@ kg
|
|
|
65
65
|
- **OpenCode(AI 对话建图 / 智能提问编译)**:使用 opencode CLI 自身的认证(`opencode auth login`),应用不经手密钥。未安装或未认证时仅这两个功能降级。
|
|
66
66
|
- 更换数据目录(`kg --data <目录>`)会使用该目录下的独立配置,互不串扰。
|
|
67
67
|
|
|
68
|
+
## MCP 外部接入(外部 Agent 读写图谱)
|
|
69
|
+
|
|
70
|
+
在 **MCP 页签** 开启"MCP 服务"后,外部 Agent(Claude Desktop、Cursor、Cline、opencode 等)即可通过标准 MCP 协议对本图谱做完整增删改查:
|
|
71
|
+
|
|
72
|
+
- **HTTP 方式(推荐,支持远程 Agent)**:端点 `http://<主机>:3000/mcp`(Streamable HTTP,JSON 响应),启用后自动生成访问令牌,支持 `Authorization: Bearer` 头或 `?token=` 参数;界面提供三类客户端配置片段一键复制
|
|
73
|
+
- **本机 stdio 方式(免令牌)**:客户端配置命令 `npx -y local-knowledge-graph --mcp`(或 `kg --mcp`);数据目录默认 `~/.local-knowledge-graph`,可用 `KG_DATA_DIR` 环境变量指定
|
|
74
|
+
- **工具集(12 个)**:查询类 kg_stats / kg_list_entities / kg_get_entity / kg_get_graph / kg_ego / kg_search / kg_cypher / kg_inference / kg_path / kg_digest / kg_export_rdf;写入类 kg_apply_ops(add/update/delete entity 与 relation,自动记操作日志 + Git 保存点)
|
|
75
|
+
- **只读模式**:勾选后外部仅可查询;**令牌管理**:一键重新生成即刻吊销旧令牌
|
|
76
|
+
- **实时同步**:图谱被任一写入方(页面、API、MCP、外部进程)修改后,网页 3 秒内自动刷新
|
|
77
|
+
|
|
68
78
|
## 功能速览
|
|
69
79
|
|
|
70
80
|
- **AI 对话建图**:右侧输入自然语言指令(联网补全),或上传文档(md/txt/pdf/docx)批量抽取三元组
|
|
@@ -105,6 +115,7 @@ kg
|
|
|
105
115
|
|
|
106
116
|
## 版本历史摘要
|
|
107
117
|
|
|
118
|
+
- **v1.8.0**:MCP 外部接入——设置新增 MCP 页签(服务开关/只读模式/令牌管理/三类客户端一键复制配置);`/mcp` Streamable HTTP 端点(令牌鉴权+CORS,12 个工具含完整 CRUD);`kg --mcp` 本机 stdio 接入;图谱变更实时同步(SSE 推送+跨进程探测,页面 3 秒内自动刷新)
|
|
108
119
|
- **v1.7.0**:六项图谱能力升级——①实体别名(增删/全局唯一/搜索与消歧联动)②同名冲突检测(创建即提示,可合并可强制)③多路径查找(2-6跳,图上高亮,MCP kg_path)④相似实体推荐(语义+结构双模式)⑤关系置信度三档(确证/推测/存疑,样式区分可过滤)与来源引用标注 ⑥智能问答附证据路径链 ⑦文档批量入图(md/txt/pdf→LLM抽取→审核勾选→来源标记入库,支持跳过审核)
|
|
109
120
|
- **v1.6.1**:修复浅色主题切换导致画布空白的问题;标题栏按钮防挤压(窄屏可横滑);修复3D样式主题刷新后不恢复的问题;新增脚本异常提示
|
|
110
121
|
- **v1.6.0**:界面主题新增浅色配色(视图设置一键切换,本地记忆);GitHub 仓库与 npm 包通过 Actions 自动同步(打 tag 即测试+发布+Release)
|
package/bin/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ if (args.includes('-h') || args.includes('--help')) {
|
|
|
23
23
|
--port <n> 服务端口(默认 3000,环境变量 PORT 同效)
|
|
24
24
|
--host <addr> 监听地址(默认 127.0.0.1,局域网访问用 0.0.0.0,环境变量 KG_HOST 同效)
|
|
25
25
|
--data <dir> 数据目录(默认 ~/.local-knowledge-graph,环境变量 KG_DATA_DIR 同效)
|
|
26
|
+
--mcp 以 MCP stdio 服务运行(供 Claude Desktop/Cursor 等客户端接入,不启动网页)
|
|
26
27
|
--no-open 启动后不自动打开浏览器
|
|
27
28
|
-v, --version 显示版本
|
|
28
29
|
-h, --help 显示本帮助
|
|
@@ -36,6 +37,13 @@ if (args.includes('-v') || args.includes('--version')) {
|
|
|
36
37
|
process.exit(0);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
if (args.includes('--mcp')) {
|
|
41
|
+
// stdio MCP:数据目录仍由 KG_DATA_DIR / --data 决定
|
|
42
|
+
const d = argValue('--data');
|
|
43
|
+
if (d) process.env.KG_DATA_DIR = d;
|
|
44
|
+
require('../mcp/server.js');
|
|
45
|
+
} else {
|
|
46
|
+
|
|
39
47
|
const { ensureLegacyMigration, isDevRepo } = require('../lib/paths');
|
|
40
48
|
|
|
41
49
|
const port = argValue('--port');
|
|
@@ -84,3 +92,4 @@ require('../server.js');
|
|
|
84
92
|
if (isDevRepo()) {
|
|
85
93
|
console.log('[开发模式] 检测到Git仓库,数据目录使用项目内 ./data');
|
|
86
94
|
}
|
|
95
|
+
}
|
package/lib/bus.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 极简进程内事件总线:图谱变更通知(SSE 推送用)
|
|
4
|
+
|
|
5
|
+
const listeners = new Set();
|
|
6
|
+
|
|
7
|
+
function on(fn) {
|
|
8
|
+
listeners.add(fn);
|
|
9
|
+
return () => listeners.delete(fn);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function emit(type, payload) {
|
|
13
|
+
for (const fn of listeners) {
|
|
14
|
+
try { fn(type, payload); } catch (_) { /* 单监听器异常不影响其余 */ }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { on, emit, count: () => listeners.size };
|
package/lib/embeddings.js
CHANGED
package/mcp/core.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP 工具核心层:工具定义 + 处理器 + JSON-RPC 分发
|
|
4
|
+
// 由 stdio(mcp/server.js)与 Streamable HTTP(mcp/http.js)两个传输层共享,保证行为一致。
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const ROOT = path.join(__dirname, '..');
|
|
9
|
+
const db = require(path.join(ROOT, 'lib', 'db'));
|
|
10
|
+
const inference = require(path.join(ROOT, 'lib', 'inference'));
|
|
11
|
+
const embeddings = require(path.join(ROOT, 'lib', 'embeddings'));
|
|
12
|
+
const rdf = require(path.join(ROOT, 'lib', 'rdf'));
|
|
13
|
+
const git = require(path.join(ROOT, 'lib', 'git'));
|
|
14
|
+
|
|
15
|
+
const TOOLS = [
|
|
16
|
+
{
|
|
17
|
+
name: 'kg_stats',
|
|
18
|
+
description: '获取知识图谱统计信息:实体数/关系数/日志数/数据库版本/推理关系数',
|
|
19
|
+
inputSchema: { type: 'object', properties: {} },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'kg_list_entities',
|
|
23
|
+
description: '列出实体(可选按category过滤,limit/offset分页)。返回total便于翻页',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
category: { type: 'string', description: '按大类过滤:物理实体/抽象实体/数值实体/时间实体' },
|
|
28
|
+
limit: { type: 'number', description: '单页条数,默认100,最大500' },
|
|
29
|
+
offset: { type: 'number', description: '起始偏移,默认0' },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'kg_get_entity',
|
|
35
|
+
description: '按id或名称获取单个实体详情(含其全部关系)',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: { id: { type: 'number' }, name: { type: 'string' } },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'kg_get_graph',
|
|
43
|
+
description: '获取图谱(实体+关系)。默认按limit截断防止超大输出;通常优先用kg_ego/kg_search缩小范围',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
entity_limit: { type: 'number', description: '实体上限,默认300,最大2000' },
|
|
48
|
+
relation_limit: { type: 'number', description: '关系上限,默认1000,最大5000' },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'kg_ego',
|
|
54
|
+
description: '以某实体为中心取N层子图(双向BFS,最短跳数)。depth省略或0表示全部层级',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
center: { type: 'string', description: '中心实体id或名称' },
|
|
59
|
+
depth: { type: 'number', description: '层数,省略或0=全部' },
|
|
60
|
+
},
|
|
61
|
+
required: ['center'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'kg_search',
|
|
66
|
+
description: '混合检索实体(语义+关键词RRF融合)。需已配置embedding key,否则退化为关键词检索',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
query: { type: 'string' },
|
|
71
|
+
top_k: { type: 'number', description: '返回条数,默认10,最大50' },
|
|
72
|
+
},
|
|
73
|
+
required: ['query'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'kg_cypher',
|
|
78
|
+
description: '迷你Cypher只读关系查询。语法:MATCH (a)-[r:类型]->(b) WHERE a.name contains 值 RETURN ... LIMIT n。WHERE支持 =/contains,类型可按大类或关系名,可用 | 分隔多值',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { query: { type: 'string' } },
|
|
82
|
+
required: ['query'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'kg_inference',
|
|
87
|
+
description: 'OWL推理:按传递/对称/逆规则推导隐性关系(附推导依据)。可传center限定中心实体',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: { center: { type: 'string', description: '可选,实体id或名称' } },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'kg_path',
|
|
95
|
+
description: '两实体关系路径枚举:返回最多5条按跳数升序的路径(每跳含关系名/类别/置信度),用于验证两个对象如何间接关联',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
from: { type: 'string', description: '起点实体id或名称' },
|
|
100
|
+
to: { type: 'string', description: '终点实体id或名称' },
|
|
101
|
+
max: { type: 'number', description: '可选,最大跳数2-6,默认4' },
|
|
102
|
+
},
|
|
103
|
+
required: ['from', 'to'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'kg_digest',
|
|
108
|
+
description: '图谱目录:实体大类、关系大类、高频关系名Top20(含数量)、样例实体、规模。回答关系类问题前先取此目录,可显著提升Cypher查询的准确性',
|
|
109
|
+
inputSchema: { type: 'object', properties: {} },
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'kg_export_rdf',
|
|
113
|
+
description: '导出全部图谱为RDF Turtle文本',
|
|
114
|
+
inputSchema: { type: 'object', properties: {} },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'kg_apply_ops',
|
|
118
|
+
description: '写入图谱操作(kg-ops协议JSON数组):add_entity/add_relation/update_entity/update_relation/delete_entity/delete_relation。所有写入均记入operation_logs并自动git保存点',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object',
|
|
121
|
+
properties: { ops: { type: 'array', items: { type: 'object' } } },
|
|
122
|
+
required: ['ops'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
function createCore({ readonly = false, source = 'MCP' } = {}) {
|
|
128
|
+
function resolveCenter(key) {
|
|
129
|
+
const ents = db.listEntities();
|
|
130
|
+
if (/^\d+$/.test(key)) {
|
|
131
|
+
const byId = ents.find((e) => e.id === Number(key));
|
|
132
|
+
if (byId) return { id: byId.id };
|
|
133
|
+
throw new Error(`实体id "${key}" 不存在`);
|
|
134
|
+
}
|
|
135
|
+
const hits = ents.filter((e) => e.name === key);
|
|
136
|
+
if (hits.length === 0) throw new Error(`实体 "${key}" 不存在`);
|
|
137
|
+
if (hits.length > 1) {
|
|
138
|
+
const e = new Error(`实体名 "${key}" 存在${hits.length}个候选,请改用id`);
|
|
139
|
+
e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
return { id: hits[0].id };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const HANDLERS = {
|
|
146
|
+
kg_stats: async () => {
|
|
147
|
+
const c = db.counts();
|
|
148
|
+
const inferred = inference.computeInferred(db.getGraph()).length;
|
|
149
|
+
const st = embeddings.status();
|
|
150
|
+
return { ...c, version: db.getVersion(), inferred_relations: inferred, embeddings_indexed: st.indexed, readonly };
|
|
151
|
+
},
|
|
152
|
+
kg_list_entities: async (args) => {
|
|
153
|
+
let ents = db.listEntities();
|
|
154
|
+
if (args.category) ents = ents.filter((e) => e.category === args.category);
|
|
155
|
+
const total = ents.length;
|
|
156
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
|
|
157
|
+
const offset = Math.max(0, Number(args.offset) || 0);
|
|
158
|
+
return { total, count: Math.min(limit, total - offset), offset, entities: ents.slice(offset, offset + limit) };
|
|
159
|
+
},
|
|
160
|
+
kg_get_entity: async (args) => {
|
|
161
|
+
let e = null;
|
|
162
|
+
if (args.id != null) e = db.getEntity(Number(args.id));
|
|
163
|
+
else if (args.name) {
|
|
164
|
+
const hits = db.listEntities().filter((x) => x.name === args.name);
|
|
165
|
+
if (hits.length > 1) throw new Error(`实体名 "${args.name}" 存在${hits.length}个候选: ${hits.map((h) => h.id).join('/')}`);
|
|
166
|
+
e = hits[0] || null;
|
|
167
|
+
}
|
|
168
|
+
if (!e) throw new Error('实体不存在(请提供id或name)');
|
|
169
|
+
const rels = db.listRelations().filter((r) => r.source_id === e.id || r.target_id === e.id);
|
|
170
|
+
return { entity: e, relations: rels };
|
|
171
|
+
},
|
|
172
|
+
kg_get_graph: async (args) => {
|
|
173
|
+
const g = db.getGraph();
|
|
174
|
+
const eLimit = Math.max(1, Math.min(Number(args.entity_limit) || 300, 2000));
|
|
175
|
+
const rLimit = Math.max(1, Math.min(Number(args.relation_limit) || 1000, 5000));
|
|
176
|
+
return {
|
|
177
|
+
total_entities: g.entities.length,
|
|
178
|
+
total_relations: g.relations.length,
|
|
179
|
+
truncated: g.entities.length > eLimit || g.relations.length > rLimit,
|
|
180
|
+
entities: g.entities.slice(0, eLimit),
|
|
181
|
+
relations: g.relations.slice(0, rLimit),
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
kg_ego: async (args) => {
|
|
185
|
+
const { id } = resolveCenter(String(args.center));
|
|
186
|
+
let depth = null;
|
|
187
|
+
if (args.depth != null && Number(args.depth) > 0) depth = Number(args.depth);
|
|
188
|
+
return db.egoSubgraph(id, depth);
|
|
189
|
+
},
|
|
190
|
+
kg_search: async (args) => {
|
|
191
|
+
const r = await embeddings.search(String(args.query || ''), args.top_k);
|
|
192
|
+
return { mode: r.mode, results: r.results.map((x) => ({ ...x })) };
|
|
193
|
+
},
|
|
194
|
+
kg_cypher: async (args) => ({ rows: db.miniCypher(String(args.query || '')) }),
|
|
195
|
+
kg_inference: async (args) => {
|
|
196
|
+
const g = db.getGraph();
|
|
197
|
+
let result = inference.computeInferred(g);
|
|
198
|
+
if (args.center) {
|
|
199
|
+
const { id } = resolveCenter(String(args.center));
|
|
200
|
+
result = result.filter((i) => i.source_id === id || i.target_id === id);
|
|
201
|
+
}
|
|
202
|
+
const byId = new Map(g.entities.map((e) => [e.id, e]));
|
|
203
|
+
return { count: result.length, inferred: result.map((i) => ({ ...i, source: byId.get(i.source_id)?.name, target: byId.get(i.target_id)?.name })) };
|
|
204
|
+
},
|
|
205
|
+
kg_digest: async () => {
|
|
206
|
+
const { digest, stats } = require(path.join(ROOT, 'lib', 'ask')).buildDigest();
|
|
207
|
+
return { digest, ...stats };
|
|
208
|
+
},
|
|
209
|
+
kg_path: async (args) => {
|
|
210
|
+
const from = resolveCenter(String(args.from || ''));
|
|
211
|
+
const to = resolveCenter(String(args.to || ''));
|
|
212
|
+
return db.findPaths(from.id, to.id, { maxHops: Number.isInteger(args.max) ? args.max : 4 });
|
|
213
|
+
},
|
|
214
|
+
kg_export_rdf: async () => ({ turtle: rdf.exportTurtle(db.getGraph()) }),
|
|
215
|
+
kg_apply_ops: async (args) => {
|
|
216
|
+
if (readonly) throw new Error('MCP运行于只读模式,写入被拒绝(可在应用设置中关闭只读)');
|
|
217
|
+
if (!Array.isArray(args.ops) || !args.ops.length) throw new Error('ops必须为非空JSON数组');
|
|
218
|
+
const applied = db.applyAgentOps(args.ops);
|
|
219
|
+
try { git.savepoint(`${source}写入: ${applied.length} 项操作`, source); } catch (_) { /* 数据目录无git时忽略 */ }
|
|
220
|
+
return { applied_count: applied.length, applied };
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return { TOOLS, HANDLERS };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// JSON-RPC 分发:返回响应对象;notification 与无需应答的方法返回 null
|
|
228
|
+
function handleRpc(core, req, serverVersion) {
|
|
229
|
+
const { id, method, params } = req || {};
|
|
230
|
+
if (method === 'initialize') {
|
|
231
|
+
return {
|
|
232
|
+
jsonrpc: '2.0', id,
|
|
233
|
+
result: {
|
|
234
|
+
protocolVersion: '2024-11-05',
|
|
235
|
+
capabilities: { tools: {} },
|
|
236
|
+
serverInfo: { name: 'local-knowledge-graph', version: serverVersion },
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
|
|
241
|
+
if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
|
|
242
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: core.TOOLS } };
|
|
243
|
+
if (method === 'tools/call') {
|
|
244
|
+
const tool = core.TOOLS.find((t) => t.name === params?.name);
|
|
245
|
+
if (!tool) return { jsonrpc: '2.0', id, error: { code: -32602, message: `未知工具: ${params?.name}` } };
|
|
246
|
+
return core.HANDLERS[tool.name](params.arguments || {}).then(
|
|
247
|
+
(result) => ({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] } }),
|
|
248
|
+
(e) => {
|
|
249
|
+
const extra = e.candidates ? ' 候选: ' + JSON.stringify(e.candidates) : '';
|
|
250
|
+
return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message + extra }] } };
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (id !== undefined) return { jsonrpc: '2.0', id, error: { code: -32601, message: `未知方法: ${method}` } };
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = { TOOLS, createCore, handleRpc };
|
package/mcp/http.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// MCP Streamable HTTP 传输层(Express 中间件)
|
|
4
|
+
// - 无状态:不签发 Session-Id,每请求独立 JSON 响应
|
|
5
|
+
// - 鉴权:Authorization: Bearer <token> 或 ?token=<token>
|
|
6
|
+
// - CORS:支持浏览器侧客户端(预检放行,无论开关状态)
|
|
7
|
+
// - 支持 JSON-RPC 单条与数组批量;notification 无响应条目
|
|
8
|
+
|
|
9
|
+
const { createCore, handleRpc } = require('./core');
|
|
10
|
+
|
|
11
|
+
const CORS_HEADERS = {
|
|
12
|
+
'Access-Control-Allow-Origin': '*',
|
|
13
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
14
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, X-Requested-With',
|
|
15
|
+
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
|
|
16
|
+
'Access-Control-Max-Age': '86400',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function tokenOk(provided, expected) {
|
|
20
|
+
if (!expected || !provided) return false;
|
|
21
|
+
const a = Buffer.from(String(provided));
|
|
22
|
+
const b = Buffer.from(String(expected));
|
|
23
|
+
return a.length === b.length && require('crypto').timingSafeEqual(a, b);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractToken(req) {
|
|
27
|
+
const h = req.headers['authorization'] || '';
|
|
28
|
+
if (/^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim();
|
|
29
|
+
if (req.query && req.query.token) return String(req.query.token);
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMcpHandler({ isEnabled, getToken, isReadonly, serverVersion }) {
|
|
34
|
+
// 每请求按当前只读状态构建核心(settings 可被随时切换)
|
|
35
|
+
return async function mcpHandler(req, res) {
|
|
36
|
+
const cors = { ...CORS_HEADERS };
|
|
37
|
+
for (const [k, v] of Object.entries(cors)) res.setHeader(k, v);
|
|
38
|
+
|
|
39
|
+
if (req.method === 'OPTIONS') return res.status(204).end();
|
|
40
|
+
|
|
41
|
+
if (!isEnabled()) return res.status(404).json({ jsonrpc: '2.0', error: { code: -32000, message: 'MCP 服务未启用(请在应用设置中开启)' } });
|
|
42
|
+
|
|
43
|
+
if (req.method === 'GET') {
|
|
44
|
+
res.setHeader('Allow', 'POST, OPTIONS');
|
|
45
|
+
return res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: '本端点仅接受 POST(Streamable HTTP, JSON 响应)' } });
|
|
46
|
+
}
|
|
47
|
+
if (req.method !== 'POST') {
|
|
48
|
+
res.setHeader('Allow', 'POST, OPTIONS');
|
|
49
|
+
return res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: '方法不允许' } });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!tokenOk(extractToken(req), getToken())) {
|
|
53
|
+
return res.status(401).json({ jsonrpc: '2.0', error: { code: -32001, message: '访问令牌缺失或不正确(请在应用 MCP 设置中获取)' } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let body = req.body;
|
|
57
|
+
if (typeof body === 'string') {
|
|
58
|
+
try { body = JSON.parse(body); } catch (_) { return res.status(400).json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: '请求体不是合法JSON' } }); }
|
|
59
|
+
}
|
|
60
|
+
if (!body || (Array.isArray(body) && !body.length)) {
|
|
61
|
+
return res.status(400).json({ jsonrpc: '2.0', id: null, error: { code: -32600, message: '无效请求' } });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const core = createCore({ readonly: !!isReadonly(), source: 'MCP' });
|
|
65
|
+
const items = Array.isArray(body) ? body : [body];
|
|
66
|
+
const responses = [];
|
|
67
|
+
for (const item of items) {
|
|
68
|
+
try {
|
|
69
|
+
const r = await Promise.resolve(handleRpc(core, item, serverVersion));
|
|
70
|
+
if (r) responses.push(r);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (item && item.id !== undefined) responses.push({ jsonrpc: '2.0', id: item.id, error: { code: -32603, message: e.message } });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(body)) return res.json(responses);
|
|
77
|
+
if (!responses.length) return res.status(202).end(); // 纯 notification
|
|
78
|
+
return res.json(responses[0]);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { createMcpHandler, tokenOk, extractToken };
|
package/mcp/server.js
CHANGED
|
@@ -6,227 +6,23 @@
|
|
|
6
6
|
// { "command": "node", "args": ["/absolute/path/to/local-knowledge-graph/mcp/server.js"],
|
|
7
7
|
// "env": { "KG_MCP_READONLY": "1" } }
|
|
8
8
|
// 设置 KG_MCP_READONLY=1 时禁用写入类工具。
|
|
9
|
+
// 工具定义与分发逻辑在 mcp/core.js(与 HTTP 端点共享)。
|
|
9
10
|
|
|
10
11
|
const path = require('path');
|
|
11
12
|
const readline = require('readline');
|
|
12
13
|
|
|
13
14
|
const ROOT = path.join(__dirname, '..');
|
|
14
15
|
const db = require(path.join(ROOT, 'lib', 'db'));
|
|
15
|
-
const
|
|
16
|
-
const embeddings = require(path.join(ROOT, 'lib', 'embeddings'));
|
|
17
|
-
const rdf = require(path.join(ROOT, 'lib', 'rdf'));
|
|
18
|
-
const git = require(path.join(ROOT, 'lib', 'git'));
|
|
16
|
+
const { createCore, handleRpc } = require(path.join(__dirname, 'core'));
|
|
19
17
|
|
|
20
18
|
const READONLY = process.env.KG_MCP_READONLY === '1';
|
|
21
19
|
db.open();
|
|
20
|
+
const core = createCore({ readonly: READONLY, source: 'MCP' });
|
|
22
21
|
|
|
23
|
-
const
|
|
24
|
-
{
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
inputSchema: { type: 'object', properties: {} },
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: 'kg_list_entities',
|
|
31
|
-
description: '列出实体(可选按category过滤,limit/offset分页)。返回total便于翻页',
|
|
32
|
-
inputSchema: {
|
|
33
|
-
type: 'object',
|
|
34
|
-
properties: {
|
|
35
|
-
category: { type: 'string', description: '按大类过滤:物理实体/抽象实体/数值实体/时间实体' },
|
|
36
|
-
limit: { type: 'number', description: '单页条数,默认100,最大500' },
|
|
37
|
-
offset: { type: 'number', description: '起始偏移,默认0' },
|
|
38
|
-
},
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
name: 'kg_get_entity',
|
|
43
|
-
description: '按id或名称获取单个实体详情(含其全部关系)',
|
|
44
|
-
inputSchema: {
|
|
45
|
-
type: 'object',
|
|
46
|
-
properties: { id: { type: 'number' }, name: { type: 'string' } },
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: 'kg_get_graph',
|
|
51
|
-
description: '获取图谱(实体+关系)。默认按limit截断防止超大输出;通常优先用kg_ego/kg_search缩小范围',
|
|
52
|
-
inputSchema: {
|
|
53
|
-
type: 'object',
|
|
54
|
-
properties: {
|
|
55
|
-
entity_limit: { type: 'number', description: '实体上限,默认300,最大2000' },
|
|
56
|
-
relation_limit: { type: 'number', description: '关系上限,默认1000,最大5000' },
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
name: 'kg_ego',
|
|
62
|
-
description: '以某实体为中心取N层子图(双向BFS,最短跳数)。depth省略或0表示全部层级',
|
|
63
|
-
inputSchema: {
|
|
64
|
-
type: 'object',
|
|
65
|
-
properties: {
|
|
66
|
-
center: { type: 'string', description: '中心实体id或名称' },
|
|
67
|
-
depth: { type: 'number', description: '层数,省略或0=全部' },
|
|
68
|
-
},
|
|
69
|
-
required: ['center'],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
name: 'kg_search',
|
|
74
|
-
description: '混合检索实体(语义+关键词RRF融合)。需已配置embedding key,否则退化为关键词检索',
|
|
75
|
-
inputSchema: {
|
|
76
|
-
type: 'object',
|
|
77
|
-
properties: {
|
|
78
|
-
query: { type: 'string' },
|
|
79
|
-
top_k: { type: 'number', description: '返回条数,默认10,最大50' },
|
|
80
|
-
},
|
|
81
|
-
required: ['query'],
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
name: 'kg_cypher',
|
|
86
|
-
description: '迷你Cypher只读关系查询。语法:MATCH (a)-[r:类型]->(b) WHERE a.name contains 值 RETURN ... LIMIT n。WHERE支持 =/contains,类型可按大类或关系名,可用 | 分隔多值',
|
|
87
|
-
inputSchema: {
|
|
88
|
-
type: 'object',
|
|
89
|
-
properties: { query: { type: 'string' } },
|
|
90
|
-
required: ['query'],
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
name: 'kg_inference',
|
|
95
|
-
description: 'OWL推理:按传递/对称/逆规则推导隐性关系(附推导依据)。可传center限定中心实体',
|
|
96
|
-
inputSchema: {
|
|
97
|
-
type: 'object',
|
|
98
|
-
properties: { center: { type: 'string', description: '可选,实体id或名称' } },
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
name: 'kg_path',
|
|
103
|
-
description: '两实体关系路径枚举:返回最多5条按跳数升序的路径(每跳含关系名/类别/置信度),用于验证两个对象如何间接关联',
|
|
104
|
-
inputSchema: {
|
|
105
|
-
type: 'object',
|
|
106
|
-
properties: {
|
|
107
|
-
from: { type: 'string', description: '起点实体id或名称' },
|
|
108
|
-
to: { type: 'string', description: '终点实体id或名称' },
|
|
109
|
-
max: { type: 'number', description: '可选,最大跳数2-6,默认4' },
|
|
110
|
-
},
|
|
111
|
-
required: ['from', 'to'],
|
|
112
|
-
},
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: 'kg_digest',
|
|
116
|
-
description: '图谱目录:实体大类、关系大类、高频关系名Top20(含数量)、样例实体、规模。回答关系类问题前先取此目录,可显著提升Cypher查询的准确性',
|
|
117
|
-
inputSchema: { type: 'object', properties: {} },
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
name: 'kg_export_rdf',
|
|
121
|
-
description: '导出全部图谱为RDF Turtle文本',
|
|
122
|
-
inputSchema: { type: 'object', properties: {} },
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
name: 'kg_apply_ops',
|
|
126
|
-
description: '写入图谱操作(kg-ops协议JSON数组):add_entity/add_relation/update_entity/update_relation/delete_entity/delete_relation。所有写入均记入operation_logs并自动git保存点',
|
|
127
|
-
inputSchema: {
|
|
128
|
-
type: 'object',
|
|
129
|
-
properties: { ops: { type: 'array', items: { type: 'object' } } },
|
|
130
|
-
required: ['ops'],
|
|
131
|
-
},
|
|
132
|
-
},
|
|
133
|
-
];
|
|
134
|
-
|
|
135
|
-
function resolveCenter(key) {
|
|
136
|
-
const ents = db.listEntities();
|
|
137
|
-
if (/^\d+$/.test(key)) {
|
|
138
|
-
const byId = ents.find((e) => e.id === Number(key));
|
|
139
|
-
if (byId) return { id: byId.id };
|
|
140
|
-
throw new Error(`实体id "${key}" 不存在`);
|
|
141
|
-
}
|
|
142
|
-
const hits = ents.filter((e) => e.name === key);
|
|
143
|
-
if (hits.length === 0) throw new Error(`实体 "${key}" 不存在`);
|
|
144
|
-
if (hits.length > 1) {
|
|
145
|
-
const e = new Error(`实体名 "${key}" 存在${hits.length}个候选,请改用id`);
|
|
146
|
-
e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
|
|
147
|
-
throw e;
|
|
148
|
-
}
|
|
149
|
-
return { id: hits[0].id };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const HANDLERS = {
|
|
153
|
-
kg_stats: async () => {
|
|
154
|
-
const c = db.counts();
|
|
155
|
-
const inferred = inference.computeInferred(db.getGraph()).length;
|
|
156
|
-
const st = embeddings.status();
|
|
157
|
-
return { ...c, version: db.getVersion(), inferred_relations: inferred, embeddings_indexed: st.indexed, readonly: READONLY };
|
|
158
|
-
},
|
|
159
|
-
kg_list_entities: async (args) => {
|
|
160
|
-
let ents = db.listEntities();
|
|
161
|
-
if (args.category) ents = ents.filter((e) => e.category === args.category);
|
|
162
|
-
const total = ents.length;
|
|
163
|
-
const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
|
|
164
|
-
const offset = Math.max(0, Number(args.offset) || 0);
|
|
165
|
-
return { total, count: Math.min(limit, total - offset), offset, entities: ents.slice(offset, offset + limit) };
|
|
166
|
-
},
|
|
167
|
-
kg_get_entity: async (args) => {
|
|
168
|
-
let e = null;
|
|
169
|
-
if (args.id != null) e = db.getEntity(Number(args.id));
|
|
170
|
-
else if (args.name) {
|
|
171
|
-
const hits = db.listEntities().filter((x) => x.name === args.name);
|
|
172
|
-
if (hits.length > 1) throw new Error(`实体名 "${args.name}" 存在${hits.length}个候选: ${hits.map((h) => h.id).join('/')}`);
|
|
173
|
-
e = hits[0] || null;
|
|
174
|
-
}
|
|
175
|
-
if (!e) throw new Error('实体不存在(请提供id或name)');
|
|
176
|
-
const rels = db.listRelations().filter((r) => r.source_id === e.id || r.target_id === e.id);
|
|
177
|
-
return { entity: e, relations: rels };
|
|
178
|
-
},
|
|
179
|
-
kg_get_graph: async (args) => {
|
|
180
|
-
const g = db.getGraph();
|
|
181
|
-
const eLimit = Math.max(1, Math.min(Number(args.entity_limit) || 300, 2000));
|
|
182
|
-
const rLimit = Math.max(1, Math.min(Number(args.relation_limit) || 1000, 5000));
|
|
183
|
-
return {
|
|
184
|
-
total_entities: g.entities.length,
|
|
185
|
-
total_relations: g.relations.length,
|
|
186
|
-
truncated: g.entities.length > eLimit || g.relations.length > rLimit,
|
|
187
|
-
entities: g.entities.slice(0, eLimit),
|
|
188
|
-
relations: g.relations.slice(0, rLimit),
|
|
189
|
-
};
|
|
190
|
-
},
|
|
191
|
-
kg_ego: async (args) => {
|
|
192
|
-
const { id } = resolveCenter(String(args.center));
|
|
193
|
-
let depth = null;
|
|
194
|
-
if (args.depth != null && Number(args.depth) > 0) depth = Number(args.depth);
|
|
195
|
-
return db.egoSubgraph(id, depth);
|
|
196
|
-
},
|
|
197
|
-
kg_search: async (args) => {
|
|
198
|
-
const r = await embeddings.search(String(args.query || ''), args.top_k);
|
|
199
|
-
return { mode: r.mode, results: r.results.map((x) => ({ ...x })) };
|
|
200
|
-
},
|
|
201
|
-
kg_cypher: async (args) => ({ rows: db.miniCypher(String(args.query || '')) }),
|
|
202
|
-
kg_inference: async (args) => {
|
|
203
|
-
const g = db.getGraph();
|
|
204
|
-
let result = inference.computeInferred(g);
|
|
205
|
-
if (args.center) {
|
|
206
|
-
const { id } = resolveCenter(String(args.center));
|
|
207
|
-
result = result.filter((i) => i.source_id === id || i.target_id === id);
|
|
208
|
-
}
|
|
209
|
-
const byId = new Map(g.entities.map((e) => [e.id, e]));
|
|
210
|
-
return { count: result.length, inferred: result.map((i) => ({ ...i, source: byId.get(i.source_id)?.name, target: byId.get(i.target_id)?.name })) };
|
|
211
|
-
},
|
|
212
|
-
kg_digest: async () => {
|
|
213
|
-
const { digest, stats } = require('./lib/ask').buildDigest();
|
|
214
|
-
return { digest, ...stats };
|
|
215
|
-
},
|
|
216
|
-
kg_path: async (args) => {
|
|
217
|
-
const from = resolveCenter(String(args.from || ''));
|
|
218
|
-
const to = resolveCenter(String(args.to || ''));
|
|
219
|
-
return db.findPaths(from.id, to.id, { maxHops: Number.isInteger(args.max) ? args.max : 4 });
|
|
220
|
-
},
|
|
221
|
-
kg_export_rdf: async () => ({ turtle: rdf.exportTurtle(db.getGraph()) }),
|
|
222
|
-
kg_apply_ops: async (args) => {
|
|
223
|
-
if (READONLY) throw new Error('MCP运行于只读模式(KG_MCP_READONLY=1),写入被拒绝');
|
|
224
|
-
if (!Array.isArray(args.ops) || !args.ops.length) throw new Error('ops必须为非空JSON数组');
|
|
225
|
-
const applied = db.applyAgentOps(args.ops);
|
|
226
|
-
git.savepoint(`MCP写入: ${applied.length} 项操作`, 'MCP');
|
|
227
|
-
return { applied_count: applied.length, applied };
|
|
228
|
-
},
|
|
229
|
-
};
|
|
22
|
+
const SERVER_VERSION = (() => {
|
|
23
|
+
try { return require(path.join(ROOT, 'package.json')).version || '1.0.0'; }
|
|
24
|
+
catch (_) { return '1.0.0'; }
|
|
25
|
+
})();
|
|
230
26
|
|
|
231
27
|
function write(msg) {
|
|
232
28
|
process.stdout.write(JSON.stringify(msg) + '\n');
|
|
@@ -237,34 +33,13 @@ function rpcError(id, code, message) {
|
|
|
237
33
|
}
|
|
238
34
|
|
|
239
35
|
async function handle(req) {
|
|
240
|
-
const { id, method
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
capabilities: { tools: {} },
|
|
247
|
-
serverInfo: { name: 'local-knowledge-graph', version: '1.3.0' },
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
if (method === 'notifications/initialized' || method === 'notifications/cancelled') return;
|
|
252
|
-
if (method === 'ping') return write({ jsonrpc: '2.0', id, result: {} });
|
|
253
|
-
if (method === 'tools/list') {
|
|
254
|
-
return write({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
255
|
-
}
|
|
256
|
-
if (method === 'tools/call') {
|
|
257
|
-
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
258
|
-
if (!tool) return rpcError(id, -32602, `未知工具: ${params?.name}`);
|
|
259
|
-
try {
|
|
260
|
-
const result = await HANDLERS[tool.name](params.arguments || {});
|
|
261
|
-
return write({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] } });
|
|
262
|
-
} catch (e) {
|
|
263
|
-
const extra = e.candidates ? ' 候选: ' + JSON.stringify(e.candidates) : '';
|
|
264
|
-
return write({ jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message + extra }] } });
|
|
265
|
-
}
|
|
36
|
+
const { id, method } = req || {};
|
|
37
|
+
try {
|
|
38
|
+
const resp = await Promise.resolve(handleRpc(core, req, SERVER_VERSION));
|
|
39
|
+
if (resp) write(resp);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (id !== undefined) rpcError(id, -32603, e.message);
|
|
266
42
|
}
|
|
267
|
-
if (id !== undefined) rpcError(id, -32601, `未知方法: ${method}`);
|
|
268
43
|
}
|
|
269
44
|
|
|
270
45
|
const rl = readline.createInterface({ input: process.stdin });
|
|
@@ -273,8 +48,6 @@ rl.on('line', (line) => {
|
|
|
273
48
|
if (!s) return;
|
|
274
49
|
let req;
|
|
275
50
|
try { req = JSON.parse(s); } catch (_) { return; }
|
|
276
|
-
|
|
277
|
-
if (req && req.id !== undefined) rpcError(req.id, -32603, e.message);
|
|
278
|
-
});
|
|
51
|
+
handle(req);
|
|
279
52
|
});
|
|
280
53
|
rl.on('close', () => process.exit(0));
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -30,6 +30,21 @@ function confBadge(r) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const $ = (id) => document.getElementById(id);
|
|
33
|
+
async function copyText(text) {
|
|
34
|
+
try {
|
|
35
|
+
await navigator.clipboard.writeText(text);
|
|
36
|
+
toast('已复制到剪贴板');
|
|
37
|
+
} catch (_) {
|
|
38
|
+
const ta = document.createElement('textarea');
|
|
39
|
+
ta.value = text;
|
|
40
|
+
ta.style.position = 'fixed';
|
|
41
|
+
ta.style.opacity = '0';
|
|
42
|
+
document.body.appendChild(ta);
|
|
43
|
+
ta.select();
|
|
44
|
+
try { document.execCommand('copy'); toast('已复制到剪贴板'); } catch (e) { toast('复制失败,请手动选择复制', true); }
|
|
45
|
+
ta.remove();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
33
48
|
function toast(msg, isErr) {
|
|
34
49
|
const t = $('toast');
|
|
35
50
|
t.textContent = msg;
|
|
@@ -2026,6 +2041,74 @@ $('a-synth').addEventListener('change', () => {
|
|
|
2026
2041
|
api('/api/ask/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ synthesis: $('a-synth').checked }) }).catch(() => {});
|
|
2027
2042
|
});
|
|
2028
2043
|
|
|
2044
|
+
/* ================= MCP 外部接入 ================= */
|
|
2045
|
+
let mcpState = { enabled: false, readonly: false, token: '', endpoint: '/mcp' };
|
|
2046
|
+
|
|
2047
|
+
function mcpSnippets() {
|
|
2048
|
+
const origin = location.origin;
|
|
2049
|
+
const http = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { url: `${origin}/mcp?token=${mcpState.token}` } } }, null, 2);
|
|
2050
|
+
const desktop = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { url: `${origin}/mcp`, headers: { Authorization: `Bearer ${mcpState.token}` } } } }, null, 2);
|
|
2051
|
+
const stdio = JSON.stringify({ mcpServers: { 'local-knowledge-graph': { command: 'npx', args: ['-y', 'local-knowledge-graph', '--mcp'] } } }, null, 2);
|
|
2052
|
+
$('mcp-snippet-http').textContent = http;
|
|
2053
|
+
$('mcp-snippet-desktop').textContent = desktop;
|
|
2054
|
+
$('mcp-snippet-stdio').textContent = stdio;
|
|
2055
|
+
$('mcp-endpoint').textContent = `${origin}${mcpState.endpoint}`;
|
|
2056
|
+
$('mcp-token').textContent = mcpState.token;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function renderMcp() {
|
|
2060
|
+
$('mcp-toggle').checked = mcpState.enabled;
|
|
2061
|
+
$('mcp-readonly').checked = mcpState.readonly;
|
|
2062
|
+
$('mcp-cfg').style.display = mcpState.enabled ? '' : 'none';
|
|
2063
|
+
$('mcp-status').textContent = mcpState.enabled
|
|
2064
|
+
? (mcpState.readonly ? '状态:已启用(只读)— 外部 Agent 仅可查询' : '状态:已启用(读写)— 外部 Agent 可查询与写入')
|
|
2065
|
+
: '状态:已停用 — /mcp 端点关闭';
|
|
2066
|
+
if (mcpState.enabled) mcpSnippets();
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
async function loadMcp() {
|
|
2070
|
+
try {
|
|
2071
|
+
mcpState = await api('/api/mcp/settings');
|
|
2072
|
+
renderMcp();
|
|
2073
|
+
} catch (_) { /* 服务未就绪时静默 */ }
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function saveMcp(patch) {
|
|
2077
|
+
try {
|
|
2078
|
+
mcpState = await api('/api/mcp/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
|
|
2079
|
+
renderMcp();
|
|
2080
|
+
} catch (e) { toast(e.message, true); }
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
$('mcp-toggle').addEventListener('change', () => saveMcp({ enabled: $('mcp-toggle').checked }));
|
|
2084
|
+
$('mcp-readonly').addEventListener('change', () => saveMcp({ readonly: $('mcp-readonly').checked }));
|
|
2085
|
+
$('mcp-regen').addEventListener('click', async () => {
|
|
2086
|
+
try {
|
|
2087
|
+
const r = await api('/api/mcp/token/regen', { method: 'POST' });
|
|
2088
|
+
mcpState.token = r.token;
|
|
2089
|
+
renderMcp();
|
|
2090
|
+
toast('令牌已重新生成,旧令牌立即失效');
|
|
2091
|
+
} catch (e) { toast(e.message, true); }
|
|
2092
|
+
});
|
|
2093
|
+
$('mcp-copy-token').addEventListener('click', () => copyText(mcpState.token));
|
|
2094
|
+
$('mcp-copy-http').addEventListener('click', () => copyText($('mcp-snippet-http').textContent));
|
|
2095
|
+
$('mcp-copy-desktop').addEventListener('click', () => copyText($('mcp-snippet-desktop').textContent));
|
|
2096
|
+
$('mcp-copy-stdio').addEventListener('click', () => copyText($('mcp-snippet-stdio').textContent));
|
|
2097
|
+
loadMcp();
|
|
2098
|
+
|
|
2099
|
+
/* ================= 实时同步:SSE + 外部变更提示 ================= */
|
|
2100
|
+
let syncTimer = null;
|
|
2101
|
+
let lastSyncToast = 0;
|
|
2102
|
+
try {
|
|
2103
|
+
const es = new EventSource('/api/events');
|
|
2104
|
+
es.addEventListener('graph-changed', () => {
|
|
2105
|
+
clearTimeout(syncTimer);
|
|
2106
|
+
syncTimer = setTimeout(() => { refreshAll().catch(() => {}); }, 800);
|
|
2107
|
+
const now = Date.now();
|
|
2108
|
+
if (now - lastSyncToast > 60000) { lastSyncToast = now; toast('图谱已被外部更新,已自动同步'); }
|
|
2109
|
+
});
|
|
2110
|
+
} catch (_) { /* 浏览器不支持时依赖手动刷新 */ }
|
|
2111
|
+
|
|
2029
2112
|
$('s-save').addEventListener('click', async () => {
|
|
2030
2113
|
const patch = { model: $('s-model').value.trim() || 'BAAI/bge-m3' };
|
|
2031
2114
|
const key = $('s-key').value.trim();
|
package/public/index.html
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
<div data-tab="entity" class="active">实体</div>
|
|
34
34
|
<div data-tab="relation">关系</div>
|
|
35
35
|
<div data-tab="search">检索</div>
|
|
36
|
+
<div data-tab="mcp">MCP</div>
|
|
36
37
|
<div data-tab="log">日志</div>
|
|
37
38
|
<div data-tab="version">版本</div>
|
|
38
39
|
</div>
|
|
@@ -165,6 +166,60 @@
|
|
|
165
166
|
</div>
|
|
166
167
|
<div class="card"><h3>提交历史(与操作日志双向绑定)</h3><div id="v-list"></div></div>
|
|
167
168
|
</div>
|
|
169
|
+
|
|
170
|
+
<div class="tabbody" id="tab-mcp">
|
|
171
|
+
<div class="card">
|
|
172
|
+
<h3>MCP 服务(外部 Agent 接入)</h3>
|
|
173
|
+
<div class="row" style="gap:16px;flex-wrap:wrap">
|
|
174
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
|
175
|
+
<input type="checkbox" id="mcp-toggle" style="width:auto"> 启用 MCP 服务
|
|
176
|
+
</label>
|
|
177
|
+
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
|
178
|
+
<input type="checkbox" id="mcp-readonly" style="width:auto"> 只读模式(禁用外部写入)
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
<div id="mcp-cfg" style="display:none">
|
|
182
|
+
<div class="row" style="align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px">
|
|
183
|
+
<span style="font-size:12px;color:#8fa3c0">端点</span>
|
|
184
|
+
<code id="mcp-endpoint" style="font-size:11px;word-break:break-all"></code>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="row" style="align-items:center;gap:8px;flex-wrap:wrap">
|
|
187
|
+
<span style="font-size:12px;color:#8fa3c0">令牌</span>
|
|
188
|
+
<code id="mcp-token" style="font-size:11px;word-break:break-all"></code>
|
|
189
|
+
<button id="mcp-copy-token" title="复制令牌">复制</button>
|
|
190
|
+
<button id="mcp-regen" title="重新生成后旧令牌立即失效">重新生成</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="hint-text">外部 Agent 凭令牌访问。令牌泄露时点击"重新生成"即刻吊销旧令牌。</div>
|
|
193
|
+
|
|
194
|
+
<h3 style="margin-top:14px">客户端一键配置</h3>
|
|
195
|
+
<label>通用 Streamable HTTP(Cline / opencode / 通用 MCP 客户端)</label>
|
|
196
|
+
<div class="row" style="align-items:flex-start;gap:8px">
|
|
197
|
+
<pre id="mcp-snippet-http" class="mcp-snippet"></pre>
|
|
198
|
+
<button id="mcp-copy-http">复制</button>
|
|
199
|
+
</div>
|
|
200
|
+
<label>Claude Desktop / Cursor(claude_desktop_config.json / mcp.json)</label>
|
|
201
|
+
<div class="row" style="align-items:flex-start;gap:8px">
|
|
202
|
+
<pre id="mcp-snippet-desktop" class="mcp-snippet"></pre>
|
|
203
|
+
<button id="mcp-copy-desktop">复制</button>
|
|
204
|
+
</div>
|
|
205
|
+
<label>本机 stdio(与网页同机时,免令牌)</label>
|
|
206
|
+
<div class="row" style="align-items:flex-start;gap:8px">
|
|
207
|
+
<pre id="mcp-snippet-stdio" class="mcp-snippet"></pre>
|
|
208
|
+
<button id="mcp-copy-stdio">复制</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="hint-text">stdio 方式命令:npx -y local-knowledge-graph --mcp(数据目录默认 ~/.local-knowledge-graph,可用环境变量 KG_DATA_DIR 指定其他目录)。远程 Agent 请使用 HTTP 方式并保管好令牌。</div>
|
|
211
|
+
</div>
|
|
212
|
+
<div id="mcp-status" class="hint-text"></div>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="card">
|
|
215
|
+
<h3>外部 Agent 可用的工具(12 个)</h3>
|
|
216
|
+
<div class="hint-text">
|
|
217
|
+
查询:kg_stats / kg_list_entities / kg_get_entity / kg_get_graph / kg_ego / kg_search / kg_cypher / kg_inference / kg_path / kg_digest / kg_export_rdf<br>
|
|
218
|
+
写入:kg_apply_ops(add_entity / add_relation / update_entity / update_relation / delete_entity / delete_relation,自动记操作日志+Git保存点)<br>
|
|
219
|
+
实时同步:任一写入方修改图谱后,页面会在 3 秒内自动刷新。
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
168
223
|
</div>
|
|
169
224
|
|
|
170
225
|
<div id="canvas-wrap">
|
package/public/style.css
CHANGED
|
@@ -504,3 +504,12 @@ html[data-theme="light"] .alias-chip { color: #2c3e57; border-color: #c9d6e8; }
|
|
|
504
504
|
html[data-theme="light"] .alias-chip i { color: #c44f27; }
|
|
505
505
|
html[data-theme="light"] .p-path { border-top-color: #dfe7f2; }
|
|
506
506
|
html[data-theme="light"] .p-head2 { color: #1a5dc8; }
|
|
507
|
+
|
|
508
|
+
/* MCP 设置片段 */
|
|
509
|
+
.mcp-snippet {
|
|
510
|
+
flex: 1; margin: 0; padding: 8px 10px; font-size: 10.5px; line-height: 1.5;
|
|
511
|
+
background: #0d1526; color: #9fb6d9; border: 1px solid #24324d; border-radius: 8px;
|
|
512
|
+
white-space: pre-wrap; word-break: break-all; max-height: 110px; overflow: auto;
|
|
513
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
514
|
+
}
|
|
515
|
+
html[data-theme="light"] .mcp-snippet { background: #f4f7fc; color: #33445e; border-color: #d8e2f0; }
|
package/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const express = require('express');
|
|
6
7
|
const db = require('./lib/db');
|
|
7
8
|
const git = require('./lib/git');
|
|
@@ -13,6 +14,30 @@ const embeddings = require('./lib/embeddings');
|
|
|
13
14
|
const askLib = require('./lib/ask');
|
|
14
15
|
const updater = require('./lib/updater');
|
|
15
16
|
const importer = require('./lib/importer');
|
|
17
|
+
const bus = require('./lib/bus');
|
|
18
|
+
const { createMcpHandler } = require('./mcp/http');
|
|
19
|
+
|
|
20
|
+
// ---------- 图谱变更总线:db 写函数统一包装,任一写入即时广播 ----------
|
|
21
|
+
for (const fn of ['addEntity', 'updateEntity', 'deleteEntity', 'addRelation', 'updateRelation', 'deleteRelation', 'addAlias', 'removeAlias', 'deleteEntityImage', 'applyAgentOps', 'undoLast', 'logRestore']) {
|
|
22
|
+
const orig = db[fn];
|
|
23
|
+
if (typeof orig === 'function') {
|
|
24
|
+
db[fn] = function (...args) {
|
|
25
|
+
const r = orig.apply(this, args);
|
|
26
|
+
bus.emit('graph-changed', { via: fn });
|
|
27
|
+
return r;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mcpServerVersion() {
|
|
33
|
+
try { return require('./package.json').version || '1.0.0'; } catch (_) { return '1.0.0'; }
|
|
34
|
+
}
|
|
35
|
+
const mcpHandler = createMcpHandler({
|
|
36
|
+
isEnabled: () => embeddings.loadSettings().mcp_enabled === true,
|
|
37
|
+
getToken: () => embeddings.loadSettings().mcp_token || '',
|
|
38
|
+
isReadonly: () => embeddings.loadSettings().mcp_readonly === true,
|
|
39
|
+
serverVersion: mcpServerVersion(),
|
|
40
|
+
});
|
|
16
41
|
|
|
17
42
|
const PORT = Number(process.env.PORT || 3000);
|
|
18
43
|
const app = express();
|
|
@@ -445,6 +470,55 @@ api.post('/git/restore', (req, res) => {
|
|
|
445
470
|
res.json(r);
|
|
446
471
|
});
|
|
447
472
|
|
|
473
|
+
// ---------- MCP 外部接入设置 ----------
|
|
474
|
+
api.get('/mcp/settings', (req, res) => {
|
|
475
|
+
const s = embeddings.loadSettings();
|
|
476
|
+
res.json({
|
|
477
|
+
enabled: s.mcp_enabled === true,
|
|
478
|
+
readonly: s.mcp_readonly === true,
|
|
479
|
+
token: s.mcp_token || '',
|
|
480
|
+
endpoint: `/mcp`,
|
|
481
|
+
version: mcpServerVersion(),
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
api.put('/mcp/settings', (req, res) => {
|
|
486
|
+
try {
|
|
487
|
+
const patch = {};
|
|
488
|
+
const b = req.body || {};
|
|
489
|
+
if (typeof b.enabled === 'boolean') patch.mcp_enabled = b.enabled;
|
|
490
|
+
if (typeof b.readonly === 'boolean') patch.mcp_readonly = b.readonly;
|
|
491
|
+
const cur = embeddings.loadSettings();
|
|
492
|
+
if (patch.mcp_enabled === true && !cur.mcp_token) patch.mcp_token = crypto.randomBytes(24).toString('hex');
|
|
493
|
+
const s = embeddings.saveSettings(patch);
|
|
494
|
+
res.json({ enabled: s.mcp_enabled === true, readonly: s.mcp_readonly === true, token: s.mcp_token || '', endpoint: `/mcp`, version: mcpServerVersion() });
|
|
495
|
+
} catch (e) { res.status(400).json({ error: e.message }); }
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
api.post('/mcp/token/regen', (req, res) => {
|
|
499
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
500
|
+
embeddings.saveSettings({ mcp_token: token });
|
|
501
|
+
res.json({ token });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---------- 实时同步:SSE 事件流 ----------
|
|
505
|
+
api.get('/events', (req, res) => {
|
|
506
|
+
res.writeHead(200, {
|
|
507
|
+
'Content-Type': 'text/event-stream',
|
|
508
|
+
'Cache-Control': 'no-cache',
|
|
509
|
+
Connection: 'keep-alive',
|
|
510
|
+
'X-Accel-Buffering': 'no',
|
|
511
|
+
});
|
|
512
|
+
res.write('retry: 3000\n\n');
|
|
513
|
+
const send = (type, payload) => {
|
|
514
|
+
try { res.write(`event: ${type}\ndata: ${JSON.stringify(payload || {})}\n\n`); } catch (_) { /* 连接已断 */ }
|
|
515
|
+
};
|
|
516
|
+
send('hello', { ts: Date.now() });
|
|
517
|
+
const unsub = bus.on((type, payload) => send(type, payload));
|
|
518
|
+
const heartbeat = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { /* 忽略 */ } }, 25000);
|
|
519
|
+
req.on('close', () => { clearInterval(heartbeat); unsub(); });
|
|
520
|
+
});
|
|
521
|
+
|
|
448
522
|
// ---------- OpenCode自然语言指令 ----------
|
|
449
523
|
// AI任务互斥:OpenCode进程最长5分钟,防止并发请求叠加进程互抢SQLite与内存
|
|
450
524
|
let agentBusy = false;
|
|
@@ -611,6 +685,9 @@ api.post('/agent/doc', async (req, res) => {
|
|
|
611
685
|
} finally { agentBusy = false; }
|
|
612
686
|
});
|
|
613
687
|
|
|
688
|
+
// MCP 端点(置于 /api 之前;内部有开关与令牌校验)
|
|
689
|
+
app.use('/mcp', mcpHandler);
|
|
690
|
+
|
|
614
691
|
app.use('/api', api);
|
|
615
692
|
|
|
616
693
|
// 统一错误处理
|
|
@@ -717,3 +794,15 @@ function scheduleRestart() {
|
|
|
717
794
|
}
|
|
718
795
|
});
|
|
719
796
|
})(0);
|
|
797
|
+
|
|
798
|
+
// ---------- 跨进程变更探测:外部进程(stdio MCP/CLI)写库时兜底广播 ----------
|
|
799
|
+
let lastSignature = '';
|
|
800
|
+
function detectExternalChange() {
|
|
801
|
+
try {
|
|
802
|
+
const sig = JSON.stringify([db.counts(), db.maxLogId()]);
|
|
803
|
+
if (lastSignature && sig !== lastSignature) bus.emit('graph-changed', { via: 'external' });
|
|
804
|
+
lastSignature = sig;
|
|
805
|
+
} catch (_) { /* 数据库短暂不可用时跳过本轮 */ }
|
|
806
|
+
}
|
|
807
|
+
setInterval(detectExternalChange, 3000).unref();
|
|
808
|
+
|