local-knowledge-graph 1.7.0 → 1.8.1

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 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/db.js CHANGED
@@ -484,26 +484,46 @@ function entNameIn(id) {
484
484
 
485
485
  // 批量应用OpenCode操作(单事务原子提交,任一违规整体回滚,保持上一个合规版本)
486
486
  // ref占位符解析:add_entity可带ref,后续关系用ref引用新实体
487
+ // add_relation 支持按名称引用(source_name/target_name):主名或别名精确匹配,事务内可见同批新建实体
487
488
  function applyAgentOps(ops) {
488
489
  const applied = [];
489
490
  const refMap = {};
490
491
  return tx(() => {
491
- for (const op of ops) {
492
- if (!op || typeof op !== 'object') throw badOp('每个操作必须为JSON对象');
492
+ for (let opIdx = 0; opIdx < ops.length; opIdx++) {
493
+ const op = ops[opIdx];
494
+ try {
495
+ if (!op || typeof op !== 'object') throw badOp('每个操作必须为JSON对象');
493
496
  switch (op.op) {
494
497
  case 'add_entity': {
495
498
  const row = addEntityCore({ name: op.name, category: op.category, attributes: op.attributes }, 'OpenCode');
499
+ if (Array.isArray(op.aliases)) {
500
+ // 同事务内联写别名(addAlias 内部自带事务,嵌套会失败)
501
+ for (const raw of op.aliases) {
502
+ const n = cleanAlias(raw);
503
+ if (!n || n.length > 200 || n === row.name) continue;
504
+ const dup = db.prepare('SELECT id FROM aliases WHERE entity_id = ? AND alias = ?').get(row.id, n);
505
+ if (dup) continue;
506
+ if (aliasConflict(n, row.id)) continue;
507
+ const info = db.prepare('INSERT INTO aliases (entity_id, alias) VALUES (?, ?)').run(row.id, n);
508
+ const arow = db.prepare('SELECT id, entity_id, alias, created_at FROM aliases WHERE id = ?').get(Number(info.lastInsertRowid));
509
+ logOp('ADD_ALIAS', { alias: arow }, 'OpenCode');
510
+ }
511
+ }
496
512
  if (op.ref !== undefined) {
497
513
  if (typeof op.ref !== 'string' || !op.ref.trim()) throw badOp('ref必须为非空字符串');
498
514
  refMap[op.ref.trim()] = row.id;
499
515
  }
500
- applied.push({ op: 'add_entity', id: row.id, name: row.name });
516
+ applied.push({ op: 'add_entity', id: row.id, name: row.name, aliases_added: Array.isArray(op.aliases) ? op.aliases.length : 0 });
501
517
  break;
502
518
  }
503
519
  case 'add_relation': {
504
- const sid = resolveRef(op.source_id, op.source_ref, refMap);
505
- const tid = resolveRef(op.target_id, op.target_ref, refMap);
506
- const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category }, 'OpenCode');
520
+ let sid;
521
+ let tid;
522
+ if (op.source_name !== undefined && op.source_id === undefined && op.source_ref === undefined) sid = resolveEntityName(op.source_name);
523
+ else sid = resolveRef(op.source_id, op.source_ref, refMap);
524
+ if (op.target_name !== undefined && op.target_id === undefined && op.target_ref === undefined) tid = resolveEntityName(op.target_name);
525
+ else tid = resolveRef(op.target_id, op.target_ref, refMap);
526
+ const row = addRelationCore({ source_id: sid, target_id: tid, name: op.name, category: op.category, confidence: op.confidence, source_ref: op.evidence_ref }, 'OpenCode');
507
527
  applied.push({ op: 'add_relation', id: row.id, name: row.name });
508
528
  break;
509
529
  }
@@ -521,7 +541,7 @@ function applyAgentOps(ops) {
521
541
  }
522
542
  case 'update_relation': {
523
543
  const patch = {};
524
- for (const k of ['source_id', 'target_id', 'name', 'category']) if (op[k] !== undefined) patch[k] = op[k];
544
+ for (const k of ['source_id', 'target_id', 'name', 'category', 'confidence', 'source_ref']) if (op[k] !== undefined) patch[k] = op[k];
525
545
  const row = updateRelationCore(Number(op.id), patch, 'OpenCode');
526
546
  applied.push({ op: 'update_relation', id: row.id, name: row.name });
527
547
  break;
@@ -534,6 +554,11 @@ function applyAgentOps(ops) {
534
554
  default:
535
555
  throw badOp(`未知操作类型"${op.op}",仅支持 add_entity/add_relation/update_entity/delete_entity/update_relation/delete_relation`);
536
556
  }
557
+ } catch (e) {
558
+ const opKind = (op && op.op) || '未知';
559
+ e.message = `第${opIdx + 1}条操作(${opKind})失败: ${e.message}`;
560
+ throw e;
561
+ }
537
562
  }
538
563
  return applied;
539
564
  });
@@ -548,6 +573,17 @@ function applyAgentOps(ops) {
548
573
  if (!Number.isInteger(n) || n <= 0) throw badOp(`source_id/target_id必须为正整数或使用ref占位符`);
549
574
  return n;
550
575
  }
576
+ // 按名称解析实体id:主名或别名精确匹配;重名返回候选
577
+ function resolveEntityName(name) {
578
+ const hits = findNameConflicts(String(name).trim());
579
+ if (hits.length === 1) return hits[0].id;
580
+ if (hits.length > 1) {
581
+ const e = badOp(`实体名 "${name}" 存在${hits.length}个候选(${hits.map((h) => `id=${h.id}`).join(', ')}),请改用 source_id/target_id`);
582
+ e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category, via: h.via }));
583
+ throw e;
584
+ }
585
+ throw badOp(`实体 "${name}" 不存在(本批或库中均未找到)`);
586
+ }
551
587
  function badOp(msg) { const e = new Error('OpenCode操作校验未通过: ' + msg); e.status = 400; return e; }
552
588
  }
553
589
 
package/lib/embeddings.js CHANGED
@@ -18,6 +18,9 @@ const DEFAULTS = {
18
18
  dim: 1024,
19
19
  api_key: '',
20
20
  rrf_k: 60,
21
+ mcp_enabled: false,
22
+ mcp_token: '',
23
+ mcp_readonly: false,
21
24
  };
22
25
 
23
26
  function loadSettings() {
package/mcp/core.js ADDED
@@ -0,0 +1,292 @@
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。add_relation 支持按名称引用(source_name/target_name,含别名,重名时返回候选要求改用id);实体间同批引用可用 ref 占位符(add_entity 传 ref,add_relation 传 source_ref/target_ref,仅同一次调用内有效);关系可带 confidence(确证/推测/存疑)与 evidence_ref(来源引用文本)。add_entity 支持 aliases 数组。所有写入均记入operation_logs并自动git保存点',
119
+ inputSchema: {
120
+ type: 'object',
121
+ properties: { ops: { type: 'array', items: { type: 'object' } } },
122
+ required: ['ops'],
123
+ },
124
+ },
125
+ {
126
+ name: 'kg_reset',
127
+ description: '清空图谱全部实体与关系(级联删除,自动保存点)。仅用于重建场景,必须显式传 confirm:true 才会执行',
128
+ inputSchema: {
129
+ type: 'object',
130
+ properties: { confirm: { type: 'boolean', description: '必须为true才执行清空' } },
131
+ required: ['confirm'],
132
+ },
133
+ },
134
+ ];
135
+
136
+ function createCore({ readonly = false, source = 'MCP' } = {}) {
137
+ function resolveCenter(key) {
138
+ const ents = db.listEntities();
139
+ if (/^\d+$/.test(key)) {
140
+ const byId = ents.find((e) => e.id === Number(key));
141
+ if (byId) return { id: byId.id };
142
+ throw new Error(`实体id "${key}" 不存在`);
143
+ }
144
+ const hits = ents.filter((e) => e.name === key);
145
+ if (hits.length === 0) {
146
+ const aliasMap = db.aliasMap();
147
+ const aliasHits = ents.filter((e) => (aliasMap[e.id] || []).some((a) => a === key));
148
+ if (aliasHits.length === 1) return { id: aliasHits[0].id };
149
+ if (aliasHits.length > 1) {
150
+ const e = new Error(`别名 "${key}" 对应${aliasHits.length}个实体,请改用id`);
151
+ e.candidates = aliasHits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
152
+ throw e;
153
+ }
154
+ throw new Error(`实体 "${key}" 不存在`);
155
+ }
156
+ if (hits.length > 1) {
157
+ const e = new Error(`实体名 "${key}" 存在${hits.length}个候选,请改用id`);
158
+ e.candidates = hits.map((h) => ({ id: h.id, name: h.name, category: h.category }));
159
+ throw e;
160
+ }
161
+ return { id: hits[0].id };
162
+ }
163
+
164
+ // add_relation 的名称引用解析已下沉到 db.applyAgentOps(事务内可见同批新建实体)
165
+
166
+ const aliasById = () => db.aliasMap();
167
+
168
+ const HANDLERS = {
169
+ kg_stats: async () => {
170
+ const c = db.counts();
171
+ const inferred = inference.computeInferred(db.getGraph()).length;
172
+ const st = embeddings.status();
173
+ return { ...c, version: db.getVersion(), inferred_relations: inferred, embeddings_indexed: st.indexed, readonly };
174
+ },
175
+ kg_list_entities: async (args) => {
176
+ let ents = db.listEntities();
177
+ if (args.category) ents = ents.filter((e) => e.category === args.category);
178
+ const total = ents.length;
179
+ const limit = Math.max(1, Math.min(Number(args.limit) || 100, 500));
180
+ const offset = Math.max(0, Number(args.offset) || 0);
181
+ const amap = aliasById();
182
+ const page = ents.slice(offset, offset + limit).map((e) => ({ ...e, aliases: amap[e.id] || [] }));
183
+ return { total, count: Math.min(limit, total - offset), offset, entities: page };
184
+ },
185
+ kg_get_entity: async (args) => {
186
+ let e = null;
187
+ if (args.id != null) e = db.getEntity(Number(args.id));
188
+ else if (args.name) {
189
+ const hits = db.listEntities().filter((x) => x.name === args.name);
190
+ if (hits.length > 1) throw new Error(`实体名 "${args.name}" 存在${hits.length}个候选: ${hits.map((h) => h.id).join('/')}`);
191
+ e = hits[0] || null;
192
+ }
193
+ if (!e) throw new Error('实体不存在(请提供id或name)');
194
+ const rels = db.listRelations().filter((r) => r.source_id === e.id || r.target_id === e.id);
195
+ return { entity: { ...e, aliases: aliasById()[e.id] || [] }, relations: rels };
196
+ },
197
+ kg_get_graph: async (args) => {
198
+ const g = db.getGraph();
199
+ const eLimit = Math.max(1, Math.min(Number(args.entity_limit) || 300, 2000));
200
+ const rLimit = Math.max(1, Math.min(Number(args.relation_limit) || 1000, 5000));
201
+ return {
202
+ total_entities: g.entities.length,
203
+ total_relations: g.relations.length,
204
+ truncated: g.entities.length > eLimit || g.relations.length > rLimit,
205
+ entities: g.entities.slice(0, eLimit),
206
+ relations: g.relations.slice(0, rLimit),
207
+ };
208
+ },
209
+ kg_ego: async (args) => {
210
+ const { id } = resolveCenter(String(args.center));
211
+ let depth = null;
212
+ if (args.depth != null && Number(args.depth) > 0) depth = Number(args.depth);
213
+ return db.egoSubgraph(id, depth);
214
+ },
215
+ kg_search: async (args) => {
216
+ const r = await embeddings.search(String(args.query || ''), args.top_k);
217
+ return { mode: r.mode, results: r.results.map((x) => ({ ...x })) };
218
+ },
219
+ kg_cypher: async (args) => ({ rows: db.miniCypher(String(args.query || '')) }),
220
+ kg_inference: async (args) => {
221
+ const g = db.getGraph();
222
+ let result = inference.computeInferred(g);
223
+ if (args.center) {
224
+ const { id } = resolveCenter(String(args.center));
225
+ result = result.filter((i) => i.source_id === id || i.target_id === id);
226
+ }
227
+ const byId = new Map(g.entities.map((e) => [e.id, e]));
228
+ return { count: result.length, inferred: result.map((i) => ({ ...i, source: byId.get(i.source_id)?.name, target: byId.get(i.target_id)?.name })) };
229
+ },
230
+ kg_digest: async () => {
231
+ const { digest, stats } = require(path.join(ROOT, 'lib', 'ask')).buildDigest();
232
+ return { digest, ...stats };
233
+ },
234
+ kg_path: async (args) => {
235
+ const from = resolveCenter(String(args.from || ''));
236
+ const to = resolveCenter(String(args.to || ''));
237
+ return db.findPaths(from.id, to.id, { maxHops: Number.isInteger(args.max) ? args.max : 4 });
238
+ },
239
+ kg_export_rdf: async () => ({ turtle: rdf.exportTurtle(db.getGraph()) }),
240
+ kg_apply_ops: async (args) => {
241
+ if (readonly) throw new Error('MCP运行于只读模式,写入被拒绝(可在应用设置中关闭只读)');
242
+ if (!Array.isArray(args.ops) || !args.ops.length) throw new Error('ops必须为非空JSON数组');
243
+ const applied = db.applyAgentOps(args.ops);
244
+ try { git.savepoint(`${source}写入: ${applied.length} 项操作`, source); } catch (_) { /* 数据目录无git时忽略 */ }
245
+ return { applied_count: applied.length, applied };
246
+ },
247
+ kg_reset: async (args) => {
248
+ if (readonly) throw new Error('MCP运行于只读模式,清空被拒绝(可在应用设置中关闭只读)');
249
+ if (args.confirm !== true) throw new Error('清空图谱是危险操作,必须显式传 confirm:true 执行');
250
+ const ids = db.listEntities().map((e) => e.id);
251
+ if (!ids.length) return { deleted_entities: 0, message: '图谱已为空' };
252
+ db.applyAgentOps(ids.map((id) => ({ op: 'delete_entity', id })));
253
+ try { git.savepoint(`${source}清空: 删除${ids.length}个实体`, source); } catch (_) { /* 忽略 */ }
254
+ return { deleted_entities: ids.length, message: '图谱已清空,可通过保存点回溯恢复' };
255
+ },
256
+ };
257
+
258
+ return { TOOLS, HANDLERS };
259
+ }
260
+
261
+ // JSON-RPC 分发:返回响应对象;notification 与无需应答的方法返回 null
262
+ function handleRpc(core, req, serverVersion) {
263
+ const { id, method, params } = req || {};
264
+ if (method === 'initialize') {
265
+ return {
266
+ jsonrpc: '2.0', id,
267
+ result: {
268
+ protocolVersion: '2024-11-05',
269
+ capabilities: { tools: {} },
270
+ serverInfo: { name: 'local-knowledge-graph', version: serverVersion },
271
+ },
272
+ };
273
+ }
274
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return null;
275
+ if (method === 'ping') return { jsonrpc: '2.0', id, result: {} };
276
+ if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: core.TOOLS } };
277
+ if (method === 'tools/call') {
278
+ const tool = core.TOOLS.find((t) => t.name === params?.name);
279
+ if (!tool) return { jsonrpc: '2.0', id, error: { code: -32602, message: `未知工具: ${params?.name}` } };
280
+ return core.HANDLERS[tool.name](params.arguments || {}).then(
281
+ (result) => ({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result, null, 1) }] } }),
282
+ (e) => {
283
+ const extra = e.candidates ? ' 候选: ' + JSON.stringify(e.candidates) : '';
284
+ return { jsonrpc: '2.0', id, result: { isError: true, content: [{ type: 'text', text: e.message + extra }] } };
285
+ }
286
+ );
287
+ }
288
+ if (id !== undefined) return { jsonrpc: '2.0', id, error: { code: -32601, message: `未知方法: ${method}` } };
289
+ return null;
290
+ }
291
+
292
+ 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 inference = require(path.join(ROOT, 'lib', 'inference'));
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 TOOLS = [
24
- {
25
- name: 'kg_stats',
26
- description: '获取知识图谱统计信息:实体数/关系数/日志数/数据库版本/推理关系数',
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, params } = req;
241
- if (method === 'initialize') {
242
- return write({
243
- jsonrpc: '2.0', id,
244
- result: {
245
- protocolVersion: '2024-11-05',
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
- Promise.resolve(handle(req)).catch((e) => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-knowledge-graph",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "本地知识图谱整合器:3D可视化 + AI对话建图 + 智能检索 + Git回溯 + RDF合规,全流程本地运行",
5
5
  "main": "server.js",
6
6
  "bin": {
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,11 +14,37 @@ 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();
19
44
  app.use(express.json({ limit: '30mb' }));
20
- app.use(express.static(path.join(__dirname, 'public')));
45
+ app.use(express.static(path.join(__dirname, 'public'), {
46
+ setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); }
47
+ }));
21
48
  app.use('/uploads', express.static(path.join(git.DATA_DIR, 'uploads')));
22
49
 
23
50
  const api = express.Router();
@@ -445,6 +472,55 @@ api.post('/git/restore', (req, res) => {
445
472
  res.json(r);
446
473
  });
447
474
 
475
+ // ---------- MCP 外部接入设置 ----------
476
+ api.get('/mcp/settings', (req, res) => {
477
+ const s = embeddings.loadSettings();
478
+ res.json({
479
+ enabled: s.mcp_enabled === true,
480
+ readonly: s.mcp_readonly === true,
481
+ token: s.mcp_token || '',
482
+ endpoint: `/mcp`,
483
+ version: mcpServerVersion(),
484
+ });
485
+ });
486
+
487
+ api.put('/mcp/settings', (req, res) => {
488
+ try {
489
+ const patch = {};
490
+ const b = req.body || {};
491
+ if (typeof b.enabled === 'boolean') patch.mcp_enabled = b.enabled;
492
+ if (typeof b.readonly === 'boolean') patch.mcp_readonly = b.readonly;
493
+ const cur = embeddings.loadSettings();
494
+ if (patch.mcp_enabled === true && !cur.mcp_token) patch.mcp_token = crypto.randomBytes(24).toString('hex');
495
+ const s = embeddings.saveSettings(patch);
496
+ res.json({ enabled: s.mcp_enabled === true, readonly: s.mcp_readonly === true, token: s.mcp_token || '', endpoint: `/mcp`, version: mcpServerVersion() });
497
+ } catch (e) { res.status(400).json({ error: e.message }); }
498
+ });
499
+
500
+ api.post('/mcp/token/regen', (req, res) => {
501
+ const token = crypto.randomBytes(24).toString('hex');
502
+ embeddings.saveSettings({ mcp_token: token });
503
+ res.json({ token });
504
+ });
505
+
506
+ // ---------- 实时同步:SSE 事件流 ----------
507
+ api.get('/events', (req, res) => {
508
+ res.writeHead(200, {
509
+ 'Content-Type': 'text/event-stream',
510
+ 'Cache-Control': 'no-cache',
511
+ Connection: 'keep-alive',
512
+ 'X-Accel-Buffering': 'no',
513
+ });
514
+ res.write('retry: 3000\n\n');
515
+ const send = (type, payload) => {
516
+ try { res.write(`event: ${type}\ndata: ${JSON.stringify(payload || {})}\n\n`); } catch (_) { /* 连接已断 */ }
517
+ };
518
+ send('hello', { ts: Date.now() });
519
+ const unsub = bus.on((type, payload) => send(type, payload));
520
+ const heartbeat = setInterval(() => { try { res.write(': ping\n\n'); } catch (_) { /* 忽略 */ } }, 25000);
521
+ req.on('close', () => { clearInterval(heartbeat); unsub(); });
522
+ });
523
+
448
524
  // ---------- OpenCode自然语言指令 ----------
449
525
  // AI任务互斥:OpenCode进程最长5分钟,防止并发请求叠加进程互抢SQLite与内存
450
526
  let agentBusy = false;
@@ -611,6 +687,9 @@ api.post('/agent/doc', async (req, res) => {
611
687
  } finally { agentBusy = false; }
612
688
  });
613
689
 
690
+ // MCP 端点(置于 /api 之前;内部有开关与令牌校验)
691
+ app.use('/mcp', mcpHandler);
692
+
614
693
  app.use('/api', api);
615
694
 
616
695
  // 统一错误处理
@@ -717,3 +796,15 @@ function scheduleRestart() {
717
796
  }
718
797
  });
719
798
  })(0);
799
+
800
+ // ---------- 跨进程变更探测:外部进程(stdio MCP/CLI)写库时兜底广播 ----------
801
+ let lastSignature = '';
802
+ function detectExternalChange() {
803
+ try {
804
+ const sig = JSON.stringify([db.counts(), db.maxLogId()]);
805
+ if (lastSignature && sig !== lastSignature) bus.emit('graph-changed', { via: 'external' });
806
+ lastSignature = sig;
807
+ } catch (_) { /* 数据库短暂不可用时跳过本轮 */ }
808
+ }
809
+ setInterval(detectExternalChange, 3000).unref();
810
+